// Ported from golang/nats-server/server/jetstream.go:414-523 (enableJetStream) // Tests for JetStreamService lifecycle orchestration: store directory creation, // API subject registration, configuration property exposure, and dispose semantics. using NATS.Server.Configuration; using NATS.Server.JetStream; namespace NATS.Server.JetStream.Tests.JetStream; public sealed class JetStreamServiceOrchestrationTests : IDisposable { private readonly List _tempDirs = []; private string MakeTempDir() { var path = Path.Combine(Path.GetTempPath(), "nats-js-test-" + Guid.NewGuid().ToString("N")); _tempDirs.Add(path); return path; } public void Dispose() { foreach (var dir in _tempDirs) { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } // Go: enableJetStream — jetstream.go:414 — happy path creates store dir and marks running [Fact] public async Task StartAsync_creates_store_directory_and_marks_running() { var storeDir = MakeTempDir(); var options = new JetStreamOptions { StoreDir = storeDir }; await using var svc = new JetStreamService(options); Directory.Exists(storeDir).ShouldBeFalse("directory must not exist before start"); await svc.StartAsync(CancellationToken.None); svc.IsRunning.ShouldBeTrue(); Directory.Exists(storeDir).ShouldBeTrue("StartAsync must create the store directory"); } // Go: enableJetStream — jetstream.go:430 — existing dir is accepted without error [Fact] public async Task StartAsync_accepts_preexisting_store_directory() { var storeDir = MakeTempDir(); Directory.CreateDirectory(storeDir); var options = new JetStreamOptions { StoreDir = storeDir }; await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); svc.IsRunning.ShouldBeTrue(); Directory.Exists(storeDir).ShouldBeTrue(); } // Go: enableJetStream — memory-only mode when StoreDir is empty [Fact] public async Task StartAsync_with_empty_StoreDir_starts_in_memory_only_mode() { var options = new JetStreamOptions { StoreDir = string.Empty }; await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); svc.IsRunning.ShouldBeTrue(); } // Go: setJetStreamExportSubs — jetstream.go:489 — all $JS.API subjects registered [Fact] public async Task RegisteredApiSubjects_contains_expected_subjects_after_start() { var options = new JetStreamOptions(); await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); var subjects = svc.RegisteredApiSubjects; subjects.ShouldNotBeEmpty(); subjects.ShouldContain("$JS.API.>"); subjects.ShouldContain("$JS.API.INFO"); subjects.ShouldContain("$JS.API.META.LEADER.STEPDOWN"); subjects.ShouldContain("$JS.API.STREAM.NAMES"); subjects.ShouldContain("$JS.API.STREAM.LIST"); } // Go: setJetStreamExportSubs — all consumer-related wildcards registered [Fact] public async Task RegisteredApiSubjects_includes_consumer_and_stream_wildcard_subjects() { var options = new JetStreamOptions(); await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); var subjects = svc.RegisteredApiSubjects; // Stream management subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.CREATE."), "stream create wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.DELETE."), "stream delete wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.INFO."), "stream info wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.UPDATE."), "stream update wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.PURGE."), "stream purge wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.MSG.GET."), "stream msg get wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.MSG.DELETE."), "stream msg delete wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.SNAPSHOT."), "stream snapshot wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.RESTORE."), "stream restore wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.LEADER.STEPDOWN."), "stream leader stepdown wildcard"); // Consumer management subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.CREATE."), "consumer create wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.DELETE."), "consumer delete wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.INFO."), "consumer info wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.NAMES."), "consumer names wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.LIST."), "consumer list wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.PAUSE."), "consumer pause wildcard"); subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.MSG.NEXT."), "consumer msg next wildcard"); // Direct get subjects.ShouldContain(s => s.StartsWith("$JS.API.DIRECT.GET."), "direct get wildcard"); } // RegisteredApiSubjects should be empty before start [Fact] public void RegisteredApiSubjects_is_empty_before_start() { var options = new JetStreamOptions(); var svc = new JetStreamService(options); svc.RegisteredApiSubjects.ShouldBeEmpty(); } // Go: shutdown path — DisposeAsync clears subjects and marks not running [Fact] public async Task DisposeAsync_clears_subjects_and_marks_not_running() { var options = new JetStreamOptions(); var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); svc.IsRunning.ShouldBeTrue(); svc.RegisteredApiSubjects.ShouldNotBeEmpty(); await svc.DisposeAsync(); svc.IsRunning.ShouldBeFalse(); svc.RegisteredApiSubjects.ShouldBeEmpty(); } // MaxStreams and MaxConsumers reflect config values [Fact] public async Task MaxStreams_and_MaxConsumers_reflect_config_values() { var options = new JetStreamOptions { MaxStreams = 100, MaxConsumers = 500, }; await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); svc.MaxStreams.ShouldBe(100); svc.MaxConsumers.ShouldBe(500); } // MaxMemory and MaxStore reflect config values [Fact] public async Task MaxMemory_and_MaxStore_reflect_config_values() { var options = new JetStreamOptions { MaxMemoryStore = 1_073_741_824L, // 1 GiB MaxFileStore = 10_737_418_240L, // 10 GiB }; await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); svc.MaxMemory.ShouldBe(1_073_741_824L); svc.MaxStore.ShouldBe(10_737_418_240L); } // Default config values are zero (unlimited) [Fact] public void Default_config_values_are_unlimited_zero() { var options = new JetStreamOptions(); var svc = new JetStreamService(options); svc.MaxStreams.ShouldBe(0); svc.MaxConsumers.ShouldBe(0); svc.MaxMemory.ShouldBe(0L); svc.MaxStore.ShouldBe(0L); } // Go: enableJetStream idempotency — double-start is safe (not an error) [Fact] public async Task Double_start_is_idempotent() { var options = new JetStreamOptions(); await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); var subjectCountAfterFirst = svc.RegisteredApiSubjects.Count; // Second start must not throw and must not duplicate subjects await svc.StartAsync(CancellationToken.None); svc.IsRunning.ShouldBeTrue(); svc.RegisteredApiSubjects.Count.ShouldBe(subjectCountAfterFirst); } // Store directory is created with a nested path (MkdirAll semantics) [Fact] public async Task StartAsync_creates_nested_store_directory() { var baseDir = MakeTempDir(); var nestedDir = Path.Combine(baseDir, "level1", "level2", "jetstream"); var options = new JetStreamOptions { StoreDir = nestedDir }; await using var svc = new JetStreamService(options); await svc.StartAsync(CancellationToken.None); svc.IsRunning.ShouldBeTrue(); Directory.Exists(nestedDir).ShouldBeTrue("nested store directory must be created"); } // Service is not running before start [Fact] public void IsRunning_is_false_before_start() { var options = new JetStreamOptions(); var svc = new JetStreamService(options); svc.IsRunning.ShouldBeFalse(); } }