// Go reference: jetstream_api.go:1200-1350 — stream purge supports options: subject filter, // sequence cutoff, and keep-last-N. Combinations like filter+keep allow keeping the last N // messages per matching subject. using System.Text; using NATS.Server.JetStream; using NATS.Server.JetStream.Api; namespace NATS.Server.Tests.JetStream.Api; public class StreamPurgeOptionsTests { private static JetStreamApiRouter CreateRouterWithStream(string streamName, string subjectPattern, out StreamManager streamManager) { streamManager = new StreamManager(); var consumerManager = new ConsumerManager(); var router = new JetStreamApiRouter(streamManager, consumerManager); var payload = Encoding.UTF8.GetBytes($$$"""{"name":"{{{streamName}}}","subjects":["{{{subjectPattern}}}"]}"""); var result = router.Route($"$JS.API.STREAM.CREATE.{streamName}", payload); result.Error.ShouldBeNull(); return router; } private static async Task PublishAsync(StreamManager streamManager, string subject, string payload) { var stream = streamManager.FindBySubject(subject); stream.ShouldNotBeNull(); await stream.Store.AppendAsync(subject, Encoding.UTF8.GetBytes(payload), default); } /// /// Purge with no options removes all messages and returns the count. /// Go reference: jetstream_api.go — basic purge with empty request body. /// [Fact] public async Task Purge_NoOptions_RemovesAll() { var router = CreateRouterWithStream("TEST", "test.>", out var sm); await PublishAsync(sm, "test.a", "1"); await PublishAsync(sm, "test.b", "2"); await PublishAsync(sm, "test.c", "3"); var result = router.Route("$JS.API.STREAM.PURGE.TEST", Encoding.UTF8.GetBytes("{}")); result.Error.ShouldBeNull(); result.Success.ShouldBeTrue(); result.Purged.ShouldBe(3UL); var state = await sm.GetStateAsync("TEST", default); state.Messages.ShouldBe(0UL); } /// /// Purge with a subject filter removes only messages matching the pattern. /// Go reference: jetstream_api.go:1200-1350 — filter option. /// [Fact] public async Task Purge_WithSubjectFilter_RemovesOnlyMatching() { var router = CreateRouterWithStream("TEST", ">", out var sm); await PublishAsync(sm, "orders.a", "1"); await PublishAsync(sm, "orders.b", "2"); await PublishAsync(sm, "logs.x", "3"); await PublishAsync(sm, "orders.c", "4"); var payload = Encoding.UTF8.GetBytes("""{"filter":"orders.*"}"""); var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload); result.Error.ShouldBeNull(); result.Success.ShouldBeTrue(); result.Purged.ShouldBe(3UL); var state = await sm.GetStateAsync("TEST", default); state.Messages.ShouldBe(1UL); } /// /// Purge with seq option removes all messages with sequence strictly less than the given value. /// Go reference: jetstream_api.go:1200-1350 — seq option. /// [Fact] public async Task Purge_WithSeq_RemovesBelowSequence() { var router = CreateRouterWithStream("TEST", "test.>", out var sm); await PublishAsync(sm, "test.a", "1"); // seq 1 await PublishAsync(sm, "test.b", "2"); // seq 2 await PublishAsync(sm, "test.c", "3"); // seq 3 await PublishAsync(sm, "test.d", "4"); // seq 4 await PublishAsync(sm, "test.e", "5"); // seq 5 // Remove all messages with seq < 4 (i.e., sequences 1, 2, 3). var payload = Encoding.UTF8.GetBytes("""{"seq":4}"""); var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload); result.Error.ShouldBeNull(); result.Success.ShouldBeTrue(); result.Purged.ShouldBe(3UL); var state = await sm.GetStateAsync("TEST", default); state.Messages.ShouldBe(2UL); } /// /// Purge with keep option retains the last N messages globally. /// Go reference: jetstream_api.go:1200-1350 — keep option. /// [Fact] public async Task Purge_WithKeep_KeepsLastN() { var router = CreateRouterWithStream("TEST", "test.>", out var sm); await PublishAsync(sm, "test.a", "1"); // seq 1 await PublishAsync(sm, "test.b", "2"); // seq 2 await PublishAsync(sm, "test.c", "3"); // seq 3 await PublishAsync(sm, "test.d", "4"); // seq 4 await PublishAsync(sm, "test.e", "5"); // seq 5 // Keep the last 2 messages (seq 4, 5); purge 1, 2, 3. var payload = Encoding.UTF8.GetBytes("""{"keep":2}"""); var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload); result.Error.ShouldBeNull(); result.Success.ShouldBeTrue(); result.Purged.ShouldBe(3UL); var state = await sm.GetStateAsync("TEST", default); state.Messages.ShouldBe(2UL); } /// /// Purge with both filter and keep retains the last N messages per matching subject. /// Go reference: jetstream_api.go:1200-1350 — filter+keep combination. /// [Fact] public async Task Purge_FilterAndKeep_KeepsLastNPerFilter() { var router = CreateRouterWithStream("TEST", ">", out var sm); // Publish multiple messages on two subjects. await PublishAsync(sm, "orders.a", "o1"); // seq 1 await PublishAsync(sm, "orders.a", "o2"); // seq 2 await PublishAsync(sm, "orders.a", "o3"); // seq 3 await PublishAsync(sm, "logs.x", "l1"); // seq 4 — not matching filter await PublishAsync(sm, "orders.b", "ob1"); // seq 5 await PublishAsync(sm, "orders.b", "ob2"); // seq 6 // Keep last 1 per matching subject "orders.*". // orders.a has 3 msgs -> keep seq 3, purge seq 1, 2 // orders.b has 2 msgs -> keep seq 6, purge seq 5 // logs.x is unaffected (does not match filter) var payload = Encoding.UTF8.GetBytes("""{"filter":"orders.*","keep":1}"""); var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload); result.Error.ShouldBeNull(); result.Success.ShouldBeTrue(); result.Purged.ShouldBe(3UL); var state = await sm.GetStateAsync("TEST", default); // Remaining: orders.a seq 3, logs.x seq 4, orders.b seq 6 = 3 messages state.Messages.ShouldBe(3UL); } /// /// Purge on a non-existent stream returns a 404 not-found error. /// Go reference: jetstream_api.go — stream not found. /// [Fact] public void Purge_InvalidStream_ReturnsNotFound() { var streamManager = new StreamManager(); var consumerManager = new ConsumerManager(); var router = new JetStreamApiRouter(streamManager, consumerManager); var result = router.Route("$JS.API.STREAM.PURGE.NONEXISTENT", Encoding.UTF8.GetBytes("{}")); result.Error.ShouldNotBeNull(); result.Error!.Code.ShouldBe(404); } /// /// Purge on an empty stream returns success with zero purged count. /// Go reference: jetstream_api.go — purge on empty stream. /// [Fact] public void Purge_EmptyStream_ReturnsZeroPurged() { var router = CreateRouterWithStream("TEST", "test.>", out _); var result = router.Route("$JS.API.STREAM.PURGE.TEST", Encoding.UTF8.GetBytes("{}")); result.Error.ShouldBeNull(); result.Success.ShouldBeTrue(); result.Purged.ShouldBe(0UL); } }