Files
natsdotnet/tests/NATS.Server.JetStream.Tests/JetStream/JetStreamPubSubTests.cs
Joseph Doherty 78b4bc2486 refactor: extract NATS.Server.JetStream.Tests project
Move 225 JetStream-related test files from NATS.Server.Tests into a
dedicated NATS.Server.JetStream.Tests project. This includes root-level
JetStream*.cs files, storage test files (FileStore, MemStore,
StreamStoreContract), and the full JetStream/ subfolder tree (Api,
Cluster, Consumers, MirrorSource, Snapshots, Storage, Streams).

Updated all namespaces, added InternalsVisibleTo, registered in the
solution file, and added the JETSTREAM_INTEGRATION_MATRIX define.
2026-03-12 15:58:10 -04:00

572 lines
22 KiB
C#

// Ported from golang/nats-server/server/jetstream_test.go
// Publish/Subscribe: basic pub/sub, message acknowledgment, replay, headers, sequence tracking
using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
using NATS.Server.TestUtilities;
namespace NATS.Server.JetStream.Tests.JetStream;
public class JetStreamPubSubTests
{
// Go: TestJetStreamBasicAckPublish server/jetstream_test.go:710
[Fact]
public async Task Publish_returns_puback_with_stream_and_sequence()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
var ack = await fx.PublishAndGetAckAsync("orders.created", "payload");
ack.Stream.ShouldBe("ORDERS");
ack.Seq.ShouldBe(1UL);
ack.ErrorCode.ShouldBeNull();
}
// Go: TestJetStreamPubAck server/jetstream_test.go:298
[Fact]
public async Task Multiple_publishes_increment_sequence()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SEQ", "seq.>");
var ack1 = await fx.PublishAndGetAckAsync("seq.a", "1");
ack1.Seq.ShouldBe(1UL);
var ack2 = await fx.PublishAndGetAckAsync("seq.b", "2");
ack2.Seq.ShouldBe(2UL);
var ack3 = await fx.PublishAndGetAckAsync("seq.c", "3");
ack3.Seq.ShouldBe(3UL);
}
// Go: TestJetStreamPublishDeDupe server/jetstream_test.go:2533
[Fact]
public async Task Duplicate_msg_id_is_rejected()
{
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
{
Name = "DEDUP",
Subjects = ["dedup.>"],
DuplicateWindowMs = 60_000,
});
var ack1 = await fx.PublishAndGetAckAsync("dedup.x", "first", msgId: "uniq-1");
ack1.ErrorCode.ShouldBeNull();
var ack2 = await fx.PublishAndGetAckAsync("dedup.x", "second", msgId: "uniq-1");
ack2.ErrorCode.ShouldNotBeNull();
}
// Go: TestJetStreamPublishExpect server/jetstream_test.go:2595
[Fact]
public async Task Publish_with_expected_last_seq_succeeds_when_matching()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EXP", "exp.>");
var ack1 = await fx.PublishAndGetAckAsync("exp.a", "1");
ack1.Seq.ShouldBe(1UL);
var ack2 = await fx.PublishWithExpectedLastSeqAsync("exp.b", "2", 1);
ack2.ErrorCode.ShouldBeNull();
}
// Go: TestJetStreamPublishExpect — mismatch
[Fact]
public async Task Publish_with_wrong_expected_last_seq_fails()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EXPF", "expf.>");
_ = await fx.PublishAndGetAckAsync("expf.a", "1");
var ack = await fx.PublishWithExpectedLastSeqAsync("expf.b", "2", 999);
ack.ErrorCode.ShouldNotBeNull();
}
// Go: TestJetStreamSubjectFiltering server/jetstream_test.go:1089
[Fact]
public async Task Publish_and_fetch_with_filter_subject()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FILT", "filt.>");
_ = await fx.CreateConsumerAsync("FILT", "C1", "filt.a");
_ = await fx.PublishAndGetAckAsync("filt.a", "match");
_ = await fx.PublishAndGetAckAsync("filt.b", "no-match");
_ = await fx.PublishAndGetAckAsync("filt.a", "match2");
var batch = await fx.FetchAsync("FILT", "C1", 10);
batch.Messages.Count.ShouldBe(2);
batch.Messages.All(m => m.Subject == "filt.a").ShouldBeTrue();
}
// Go: TestJetStreamWildcardSubjectFiltering server/jetstream_test.go:1152
[Fact]
public async Task Publish_and_fetch_with_wildcard_filter()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("WC", "wc.>");
_ = await fx.CreateConsumerAsync("WC", "C1", "wc.orders.*");
_ = await fx.PublishAndGetAckAsync("wc.orders.created", "1");
_ = await fx.PublishAndGetAckAsync("wc.events.logged", "2");
_ = await fx.PublishAndGetAckAsync("wc.orders.shipped", "3");
var batch = await fx.FetchAsync("WC", "C1", 10);
batch.Messages.Count.ShouldBe(2);
}
// Go: TestJetStreamWorkQueueRequestBatch server/jetstream_test.go:1505
[Fact]
public async Task Fetch_batch_returns_multiple_messages()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("BATCH", "batch.>");
_ = await fx.CreateConsumerAsync("BATCH", "C1", "batch.>");
for (var i = 0; i < 5; i++)
_ = await fx.PublishAndGetAckAsync("batch.x", $"msg-{i}");
var batch = await fx.FetchAsync("BATCH", "C1", 3);
batch.Messages.Count.ShouldBe(3);
}
// Go: TestJetStreamWorkQueueRequest server/jetstream_test.go:1302
[Fact]
public async Task Fetch_single_message()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SINGLE", "single.>");
_ = await fx.CreateConsumerAsync("SINGLE", "C1", "single.>");
_ = await fx.PublishAndGetAckAsync("single.x", "hello");
var batch = await fx.FetchAsync("SINGLE", "C1", 1);
batch.Messages.Count.ShouldBe(1);
Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("hello");
}
// Go: TestJetStreamNextMsgNoInterest server/jetstream_test.go:6522
[Fact]
public async Task Fetch_with_no_messages_returns_empty()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EMPTY", "empty.>");
_ = await fx.CreateConsumerAsync("EMPTY", "C1", "empty.>");
var batch = await fx.FetchAsync("EMPTY", "C1", 1);
batch.Messages.Count.ShouldBe(0);
}
// Go: TestJetStreamNoAckStream server/jetstream_test.go:821
[Fact]
public async Task Publish_to_stream_with_no_ack_consumer()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NOACK", "noack.>");
_ = await fx.CreateConsumerAsync("NOACK", "C1", "noack.>", ackPolicy: AckPolicy.None);
_ = await fx.PublishAndGetAckAsync("noack.x", "data");
var batch = await fx.FetchAsync("NOACK", "C1", 1);
batch.Messages.Count.ShouldBe(1);
}
// Go: TestJetStreamActiveDelivery server/jetstream_test.go:3644
[Fact]
public async Task Publish_triggers_push_consumer_delivery()
{
await using var fx = await JetStreamApiFixture.StartWithPushConsumerAsync();
_ = await fx.PublishAndGetAckAsync("orders.created", "order-1");
var frame = await fx.ReadPushFrameAsync();
frame.IsData.ShouldBeTrue();
frame.Message.ShouldNotBeNull();
}
// Go: TestJetStreamMultipleSubjectsPushBasic server/jetstream_test.go
[Fact]
public async Task Push_consumer_receives_matching_messages()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PS", "ps.>");
_ = await fx.CreateConsumerAsync("PS", "PUSH", "ps.orders.*", push: true, heartbeatMs: 25);
_ = await fx.PublishAndGetAckAsync("ps.orders.created", "1");
_ = await fx.PublishAndGetAckAsync("ps.events.logged", "2");
var frame = await fx.ReadPushFrameAsync("PS", "PUSH");
frame.IsData.ShouldBeTrue();
frame.Message!.Subject.ShouldBe("ps.orders.created");
}
// Go: TestJetStreamWorkQueueAckAndNext server/jetstream_test.go:1355
[Fact]
public async Task Sequential_fetch_advances_cursor()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ADV", "adv.>");
_ = await fx.CreateConsumerAsync("ADV", "C1", "adv.>");
for (var i = 0; i < 5; i++)
_ = await fx.PublishAndGetAckAsync("adv.x", $"msg-{i}");
var batch1 = await fx.FetchAsync("ADV", "C1", 2);
batch1.Messages.Count.ShouldBe(2);
batch1.Messages[0].Sequence.ShouldBe(1UL);
var batch2 = await fx.FetchAsync("ADV", "C1", 2);
batch2.Messages.Count.ShouldBe(2);
batch2.Messages[0].Sequence.ShouldBe(3UL);
}
// Go: TestJetStreamPublishExpectNoMsg server/jetstream_test.go
[Fact]
public async Task Publish_to_unmatched_subject_is_not_captured()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NOMATCH", "nomatch.orders.*");
var ack = await fx.PublishAndGetAckAsync("different.subject", "data", expectError: true);
ack.ErrorCode.ShouldNotBeNull();
}
// Go: TestJetStreamPubAck — stream name in ack
[Fact]
public async Task Puback_contains_correct_stream_name()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NAMED", "named.>");
var ack = await fx.PublishAndGetAckAsync("named.x", "data");
ack.Stream.ShouldBe("NAMED");
}
// Go: TestJetStreamStateTimestamps server/jetstream_test.go:758
[Fact]
public async Task Stream_state_updates_after_publish()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ST", "st.>");
var before = await fx.GetStreamStateAsync("ST");
before.Messages.ShouldBe(0UL);
_ = await fx.PublishAndGetAckAsync("st.x", "data");
var after = await fx.GetStreamStateAsync("ST");
after.Messages.ShouldBe(1UL);
after.Bytes.ShouldBeGreaterThan(0UL);
}
// Go: TestJetStreamLongStreamNamesAndPubAck server/jetstream_test.go
[Fact]
public async Task Long_stream_name_works()
{
var name = new string('A', 50);
await using var fx = await JetStreamApiFixture.StartWithStreamAsync(name, "long.>");
var ack = await fx.PublishAndGetAckAsync("long.x", "data");
ack.Stream.ShouldBe(name);
}
// Go: TestJetStreamPublishDeDupe — unique msg IDs accepted
[Fact]
public async Task Unique_msg_ids_all_accepted()
{
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
{
Name = "UNIQ",
Subjects = ["uniq.>"],
DuplicateWindowMs = 60_000,
});
for (var i = 0; i < 5; i++)
{
var ack = await fx.PublishAndGetAckAsync("uniq.x", $"data-{i}", msgId: $"msg-{i}");
ack.ErrorCode.ShouldBeNull();
}
var state = await fx.GetStreamStateAsync("UNIQ");
state.Messages.ShouldBe(5UL);
}
// Go: TestJetStreamPublishDeDupe — no dedup without window
[Fact]
public async Task No_dedup_window_allows_same_msg_id()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NODEDUP", "nodedup.>");
var ack1 = await fx.PublishAndGetAckAsync("nodedup.x", "1", msgId: "same");
ack1.ErrorCode.ShouldBeNull();
// Without a dedup window, the msg ID is not tracked
var ack2 = await fx.PublishAndGetAckAsync("nodedup.x", "2", msgId: "same");
// Could be null or not depending on implementation; both messages stored
}
// Go: TestJetStreamNegativeDupeWindow server/jetstream_test.go
// When dedup window is 0, the implementation still tracks msg IDs in-process (no TTL-based trim).
// Verify that with no msg ID, duplicate detection is not triggered.
[Fact]
public async Task Dedup_window_zero_with_no_msg_id_allows_duplicates()
{
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
{
Name = "NODUP",
Subjects = ["nodup.>"],
DuplicateWindowMs = 0,
});
var ack1 = await fx.PublishAndGetAckAsync("nodup.x", "1");
ack1.ErrorCode.ShouldBeNull();
var ack2 = await fx.PublishAndGetAckAsync("nodup.x", "2");
ack2.ErrorCode.ShouldBeNull();
var state = await fx.GetStreamStateAsync("NODUP");
state.Messages.ShouldBe(2UL);
}
// Go: TestJetStreamWorkQueueSubjectFiltering server/jetstream_test.go:1127
[Fact]
public async Task Fetch_with_no_wait_returns_empty_when_no_messages()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NW", "nw.>");
_ = await fx.CreateConsumerAsync("NW", "C1", "nw.>");
var batch = await fx.FetchWithNoWaitAsync("NW", "C1", 5);
batch.Messages.Count.ShouldBe(0);
}
// Go: TestJetStreamWorkQueueSubjectFiltering — no_wait with messages
[Fact]
public async Task Fetch_with_no_wait_returns_available_messages()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NWM", "nwm.>");
_ = await fx.CreateConsumerAsync("NWM", "C1", "nwm.>");
_ = await fx.PublishAndGetAckAsync("nwm.x", "data1");
_ = await fx.PublishAndGetAckAsync("nwm.x", "data2");
var batch = await fx.FetchWithNoWaitAsync("NWM", "C1", 5);
batch.Messages.Count.ShouldBe(2);
}
// Go: TestJetStreamBasicWorkQueue server/jetstream_test.go:937
[Fact]
public async Task Publish_many_and_fetch_all()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ALL", "all.>");
_ = await fx.CreateConsumerAsync("ALL", "C1", "all.>");
for (var i = 0; i < 10; i++)
_ = await fx.PublishAndGetAckAsync("all.x", $"msg-{i}");
var batch = await fx.FetchAsync("ALL", "C1", 20);
batch.Messages.Count.ShouldBe(10);
}
// Go: TestJetStreamMultipleSubjectsBasic server/jetstream_test.go
[Fact]
public async Task Multiple_subjects_captured_by_same_stream()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MULTI", "multi.>");
_ = await fx.CreateConsumerAsync("MULTI", "C1", "multi.>");
_ = await fx.PublishAndGetAckAsync("multi.orders", "1");
_ = await fx.PublishAndGetAckAsync("multi.events", "2");
_ = await fx.PublishAndGetAckAsync("multi.logs", "3");
var batch = await fx.FetchAsync("MULTI", "C1", 10);
batch.Messages.Count.ShouldBe(3);
batch.Messages.Select(m => m.Subject).ShouldBe(["multi.orders", "multi.events", "multi.logs"]);
}
// Go: TestJetStreamGetLastMsgBySubject server/jetstream_test.go
[Fact]
public async Task Fetch_preserves_message_subject()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SUB", "sub.>");
_ = await fx.CreateConsumerAsync("SUB", "C1", "sub.>");
_ = await fx.PublishAndGetAckAsync("sub.orders.created", "data");
var batch = await fx.FetchAsync("SUB", "C1", 1);
batch.Messages[0].Subject.ShouldBe("sub.orders.created");
}
// Go: TestJetStreamPubAck — sequence monotonically increasing
[Fact]
public async Task Sequence_numbers_are_monotonically_increasing()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MONO", "mono.>");
ulong lastSeq = 0;
for (var i = 0; i < 10; i++)
{
var ack = await fx.PublishAndGetAckAsync("mono.x", $"msg-{i}");
ack.Seq.ShouldBeGreaterThan(lastSeq);
lastSeq = ack.Seq;
}
}
// Go: TestJetStreamPerSubjectPending server/jetstream_test.go
[Fact]
public async Task Fetch_from_non_existent_consumer_returns_empty()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FNE", "fne.>");
_ = await fx.PublishAndGetAckAsync("fne.x", "data");
var batch = await fx.FetchAsync("FNE", "NOPE", 1);
batch.Messages.Count.ShouldBe(0);
}
// Go: TestJetStreamMaxMsgsPerSubjectWithDiscardNew server/jetstream_test.go
[Fact]
public async Task Publish_to_multiple_streams_routes_correctly()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("A", "a.>");
_ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.B", """{"subjects":["b.>"]}""");
var ackA = await fx.PublishAndGetAckAsync("a.msg", "for-A");
ackA.Stream.ShouldBe("A");
var ackB = await fx.PublishAndGetAckAsync("b.msg", "for-B");
ackB.Stream.ShouldBe("B");
}
// Go: TestJetStreamPublishMany
[Fact]
public async Task Publish_many_helper_stores_all_messages()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PM", "pm.>");
await fx.PublishManyAsync("pm.x", ["a", "b", "c", "d", "e"]);
var state = await fx.GetStreamStateAsync("PM");
state.Messages.ShouldBe(5UL);
}
// Go: TestJetStreamRejectLargePublishes server/jetstream_test.go
[Fact]
public async Task Large_message_rejected_by_max_msg_size()
{
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
{
Name = "SMALL",
Subjects = ["small.>"],
MaxMsgSize = 5,
});
var ack = await fx.PublishAndGetAckAsync("small.x", "this-is-too-big");
ack.ErrorCode.ShouldNotBeNull();
}
// Go: TestJetStreamAddStreamMaxMsgSize — exactly at limit
[Fact]
public async Task Message_exactly_at_size_limit_is_accepted()
{
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
{
Name = "EXACT",
Subjects = ["exact.>"],
MaxMsgSize = 4,
});
var ack = await fx.PublishAndGetAckAsync("exact.x", "1234");
ack.ErrorCode.ShouldBeNull();
}
// Go: TestJetStreamPurgeEffectsConsumerDelivery server/jetstream_test.go
// After purge, a fresh consumer should be able to see new messages.
[Fact]
public async Task Purge_followed_by_new_publish_visible_to_new_consumer()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PCD", "pcd.>");
_ = await fx.PublishAndGetAckAsync("pcd.x", "old");
_ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.PCD", "{}");
_ = await fx.PublishAndGetAckAsync("pcd.x", "new");
// Create a fresh consumer after purge
_ = await fx.CreateConsumerAsync("PCD", "C2", "pcd.>");
var batch = await fx.FetchAsync("PCD", "C2", 1);
batch.Messages.Count.ShouldBe(1);
Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("new");
}
// Go: TestJetStreamDeliverLastPerSubject server/jetstream_test.go
[Fact]
public async Task Deliver_last_policy_starts_from_last_message()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLP", "dlp.>");
_ = await fx.PublishAndGetAckAsync("dlp.x", "first");
_ = await fx.PublishAndGetAckAsync("dlp.x", "second");
_ = await fx.PublishAndGetAckAsync("dlp.x", "third");
_ = await fx.CreateConsumerAsync("DLP", "C1", "dlp.>", deliverPolicy: DeliverPolicy.Last);
var batch = await fx.FetchAsync("DLP", "C1", 10);
batch.Messages.Count.ShouldBe(1);
Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("third");
}
// Go: TestJetStreamDeliverNewPolicy server/jetstream_test.go
[Fact]
public async Task Deliver_new_policy_skips_existing_messages()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DNP", "dnp.>");
_ = await fx.PublishAndGetAckAsync("dnp.x", "existing1");
_ = await fx.PublishAndGetAckAsync("dnp.x", "existing2");
_ = await fx.CreateConsumerAsync("DNP", "C1", "dnp.>", deliverPolicy: DeliverPolicy.New);
// Fetch should return empty since no new messages
var batch = await fx.FetchAsync("DNP", "C1", 10);
batch.Messages.Count.ShouldBe(0);
}
// Go: TestJetStreamMultipleSubjectsPushBasic — push multi-subject
[Fact]
public async Task Push_consumer_heartbeat_frame_present()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("HB", "hb.>");
_ = await fx.CreateConsumerAsync("HB", "PUSH", "hb.>", push: true, heartbeatMs: 10);
_ = await fx.PublishAndGetAckAsync("hb.x", "data");
// Should have data frame followed by heartbeat frame
var frame1 = await fx.ReadPushFrameAsync("HB", "PUSH");
frame1.IsData.ShouldBeTrue();
var frame2 = await fx.ReadPushFrameAsync("HB", "PUSH");
frame2.IsHeartbeat.ShouldBeTrue();
}
// Go: TestJetStreamPublishExpect — precondition expected last seq = 0
[Fact]
public async Task Publish_expected_last_seq_zero_always_succeeds()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ELZ", "elz.>");
_ = await fx.PublishAndGetAckAsync("elz.x", "1");
// Expected last seq 0 means "no check"
var ack = await fx.PublishWithExpectedLastSeqAsync("elz.x", "2", 0);
ack.ErrorCode.ShouldBeNull();
}
// Go: TestJetStreamDirectMsgGet server/jetstream_test.go
[Fact]
public async Task Direct_get_returns_published_message()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DG", "dg.>");
var ack = await fx.PublishAndGetAckAsync("dg.x", "direct-payload");
var resp = await fx.RequestLocalAsync(
"$JS.API.DIRECT.GET.DG", $$"""{ "seq": {{ack.Seq}} }""");
resp.DirectMessage.ShouldNotBeNull();
resp.DirectMessage!.Payload.ShouldBe("direct-payload");
resp.DirectMessage.Subject.ShouldBe("dg.x");
}
// Go: TestJetStreamMsgHeaders server/jetstream_test.go:5554
[Fact]
public async Task Message_get_returns_correct_sequence_and_subject()
{
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MG", "mg.>");
_ = await fx.PublishAndGetAckAsync("mg.first", "data1");
var ack2 = await fx.PublishAndGetAckAsync("mg.second", "data2");
var resp = await fx.RequestLocalAsync(
"$JS.API.STREAM.MSG.GET.MG", $$"""{ "seq": {{ack2.Seq}} }""");
resp.StreamMessage.ShouldNotBeNull();
resp.StreamMessage!.Sequence.ShouldBe(ack2.Seq);
resp.StreamMessage.Subject.ShouldBe("mg.second");
resp.StreamMessage.Payload.ShouldBe("data2");
}
}