// Port of Go tests from golang/nats-server/server/jetstream_test.go // TestJetStreamPubAck, TestJetStreamPublishDeDupe, TestJetStreamPublishExpect using NATS.Server.JetStream; using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Publish; using NATS.Server.TestUtilities; namespace NATS.Server.JetStream.Tests.JetStream; public class PublishAckParityTests { // Go ref: TestJetStreamPubAck (jetstream_test.go:354) // Verifies that each published message returns a PubAck with the correct stream // name and a monotonically incrementing sequence number. [Fact] public async Task PubAck_stream_name_and_incrementing_seq_are_returned() { await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("PUBACK", "foo"); for (var i = 1UL; i <= 5UL; i++) { var ack = await fixture.PublishAndGetAckAsync("foo", "HELLO"); ack.Stream.ShouldBe("PUBACK"); ack.Seq.ShouldBe(i); ack.ErrorCode.ShouldBeNull(); } } // Go ref: TestJetStreamPublishDeDupe (jetstream_test.go:2657) — first block // When the same Nats-Msg-Id is published twice within the duplicate window the // server returns the original sequence and does not store a second message. [Fact] public async Task Duplicate_msgid_within_window_returns_same_sequence() { var streamManager = new StreamManager(); streamManager.CreateOrUpdate(new StreamConfig { Name = "DEDUPE", Subjects = ["foo.*"], DuplicateWindowMs = 2_000, }).Error.ShouldBeNull(); var publisher = new JetStreamPublisher(streamManager); // First publish — should store at seq 1 publisher.TryCaptureWithOptions("foo.1", "Hello DeDupe!"u8.ToArray(), new PublishOptions { MsgId = "AA" }, out var first).ShouldBeTrue(); first.ErrorCode.ShouldBeNull(); first.Seq.ShouldBe(1UL); // Second publish — same MsgId within window, should return the original seq publisher.TryCaptureWithOptions("foo.1", "Hello DeDupe!"u8.ToArray(), new PublishOptions { MsgId = "AA" }, out var second).ShouldBeTrue(); second.Seq.ShouldBe(first.Seq); // Stream should still contain only one message var state = await streamManager.GetStateAsync("DEDUPE", default); state.Messages.ShouldBe(1UL); } // Go ref: TestJetStreamPublishDeDupe (jetstream_test.go:2728) — window-expiry block // After the duplicate window has elapsed the same MsgId is treated as a new publish // and gets a new, higher sequence number. [Fact] public async Task Duplicate_msgid_after_window_expiry_creates_new_message() { var streamManager = new StreamManager(); streamManager.CreateOrUpdate(new StreamConfig { Name = "DEDUPE2", Subjects = ["bar.*"], DuplicateWindowMs = 30, }).Error.ShouldBeNull(); var publisher = new JetStreamPublisher(streamManager); publisher.TryCaptureWithOptions("bar.1", "first"u8.ToArray(), new PublishOptions { MsgId = "M1" }, out var first).ShouldBeTrue(); first.ErrorCode.ShouldBeNull(); // Wait for the duplicate window to expire await Task.Delay(60); // Same MsgId after window — should be treated as a new message publisher.TryCaptureWithOptions("bar.1", "after-window"u8.ToArray(), new PublishOptions { MsgId = "M1" }, out var third).ShouldBeTrue(); third.ErrorCode.ShouldBeNull(); third.Seq.ShouldBeGreaterThan(first.Seq); // Both messages should now be stored var state = await streamManager.GetStateAsync("DEDUPE2", default); state.Messages.ShouldBe(2UL); } // Go ref: TestJetStreamPublishDeDupe (jetstream_test.go:2716) — four-distinct-ids block // Multiple distinct MsgIds within the window are all stored as separate messages. [Fact] public async Task Distinct_msgids_within_window_each_stored_as_separate_message() { var streamManager = new StreamManager(); streamManager.CreateOrUpdate(new StreamConfig { Name = "DEDUPED", Subjects = ["foo.*"], DuplicateWindowMs = 2_000, }).Error.ShouldBeNull(); var publisher = new JetStreamPublisher(streamManager); var ids = new[] { "AA", "BB", "CC", "ZZ" }; for (var i = 0; i < ids.Length; i++) { publisher.TryCaptureWithOptions($"foo.{i + 1}", "Hello DeDupe!"u8.ToArray(), new PublishOptions { MsgId = ids[i] }, out var ack).ShouldBeTrue(); ack.ErrorCode.ShouldBeNull(); ack.Seq.ShouldBe((ulong)(i + 1)); } var state = await streamManager.GetStateAsync("DEDUPED", default); state.Messages.ShouldBe(4UL); // Re-sending the same MsgIds must NOT increase the message count foreach (var id in ids) { publisher.TryCaptureWithOptions("foo.1", "Hello DeDupe!"u8.ToArray(), new PublishOptions { MsgId = id }, out _).ShouldBeTrue(); } state = await streamManager.GetStateAsync("DEDUPED", default); state.Messages.ShouldBe(4UL); } // Go ref: TestJetStreamPublishExpect (jetstream_test.go:2817) — expected-last-seq block // Publishing with an ExpectedLastSeq that does not match the current last sequence // of the stream must return error code 10071. [Fact] public async Task Expected_last_seq_mismatch_returns_error_code_10071() { await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("EXPECT", "foo.*"); // Publish one message so the stream has last seq = 1 var first = await fixture.PublishAndGetAckAsync("foo.bar", "HELLO"); first.Seq.ShouldBe(1UL); first.ErrorCode.ShouldBeNull(); // Expect last seq = 10 — this must fail because actual is 1 var bad = await fixture.PublishWithExpectedLastSeqAsync("foo.bar", "HELLO", expectedLastSeq: 10); bad.ErrorCode.ShouldBe(10071); } }