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.
This commit is contained in:
Joseph Doherty
2026-03-12 15:58:10 -04:00
parent 36b9dfa654
commit 78b4bc2486
228 changed files with 253 additions and 227 deletions

View File

@@ -0,0 +1,872 @@
// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go
// Covers: cluster stream creation, single/multi replica, memory store,
// stream purge, update subjects, delete, max bytes, stream info/list,
// interest retention, work queue retention, mirror/source in cluster.
using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
namespace NATS.Server.JetStream.Tests.JetStream.Cluster;
/// <summary>
/// Tests covering clustered JetStream stream creation, replication, storage,
/// purge, update, delete, retention policies, and mirror/source in cluster mode.
/// Ported from Go jetstream_cluster_1_test.go.
/// </summary>
public class JetStreamClusterStreamTests
{
// ---------------------------------------------------------------
// Go: TestJetStreamClusterSingleReplicaStreams server/jetstream_cluster_1_test.go:223
// ---------------------------------------------------------------
[Fact]
public async Task Single_replica_stream_creation_and_publish_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var resp = await fx.CreateStreamAsync("R1S", ["foo", "bar"], replicas: 1);
resp.Error.ShouldBeNull();
resp.StreamInfo.ShouldNotBeNull();
resp.StreamInfo!.Config.Name.ShouldBe("R1S");
const int toSend = 10;
for (var i = 0; i < toSend; i++)
{
var ack = await fx.PublishAsync("foo", $"Hello R1 {i}");
ack.Stream.ShouldBe("R1S");
ack.Seq.ShouldBe((ulong)(i + 1));
}
var info = await fx.GetStreamInfoAsync("R1S");
info.StreamInfo!.State.Messages.ShouldBe((ulong)toSend);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMultiReplicaStreamsDefaultFileMem server/jetstream_cluster_1_test.go:355
// ---------------------------------------------------------------
[Fact]
public async Task Multi_replica_stream_defaults_to_memory_store()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var resp = await fx.CreateStreamAsync("MEMTEST", ["mem.>"], replicas: 3);
resp.Error.ShouldBeNull();
resp.StreamInfo!.Config.Storage.ShouldBe(StorageType.Memory);
var backend = fx.GetStoreBackendType("MEMTEST");
backend.ShouldBe("memory");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMemoryStore server/jetstream_cluster_1_test.go:423
// ---------------------------------------------------------------
[Fact]
public async Task Memory_store_replicated_stream_accepts_100_messages()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var resp = await fx.CreateStreamAsync("R3M", ["foo", "bar"], replicas: 3, storage: StorageType.Memory);
resp.Error.ShouldBeNull();
const int toSend = 100;
for (var i = 0; i < toSend; i++)
{
var ack = await fx.PublishAsync("foo", "Hello MemoryStore");
ack.Stream.ShouldBe("R3M");
}
var info = await fx.GetStreamInfoAsync("R3M");
info.StreamInfo!.Config.Name.ShouldBe("R3M");
info.StreamInfo.State.Messages.ShouldBe((ulong)toSend);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDelete server/jetstream_cluster_1_test.go:472
// ---------------------------------------------------------------
[Fact]
public async Task Delete_consumer_then_stream_clears_account_info()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("C22", ["foo", "bar", "baz"], replicas: 2);
await fx.CreateConsumerAsync("C22", "dlc");
// Delete consumer then stream
var delConsumer = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}C22.dlc", "{}");
delConsumer.Success.ShouldBeTrue();
var delStream = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}C22", "{}");
delStream.Success.ShouldBeTrue();
// Account info should show zero streams
var accountInfo = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
accountInfo.AccountInfo.ShouldNotBeNull();
accountInfo.AccountInfo!.Streams.ShouldBe(0);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamPurge server/jetstream_cluster_1_test.go:522
// ---------------------------------------------------------------
[Fact]
public async Task Stream_purge_clears_all_messages_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 5);
await fx.CreateStreamAsync("PURGE", ["foo", "bar"], replicas: 3);
const int toSend = 100;
for (var i = 0; i < toSend; i++)
await fx.PublishAsync("foo", "Hello JS Clustering");
var before = await fx.GetStreamInfoAsync("PURGE");
before.StreamInfo!.State.Messages.ShouldBe((ulong)toSend);
var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGE", "{}");
purge.Success.ShouldBeTrue();
var after = await fx.GetStreamInfoAsync("PURGE");
after.StreamInfo!.State.Messages.ShouldBe(0UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamUpdateSubjects server/jetstream_cluster_1_test.go:571
// ---------------------------------------------------------------
[Fact]
public async Task Stream_update_subjects_reflects_new_configuration()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SUBUPDATE", ["foo", "bar"], replicas: 3);
// Update subjects to bar, baz
var update = fx.UpdateStream("SUBUPDATE", ["bar", "baz"], replicas: 3);
update.Error.ShouldBeNull();
update.StreamInfo.ShouldNotBeNull();
update.StreamInfo!.Config.Subjects.ShouldContain("bar");
update.StreamInfo.Config.Subjects.ShouldContain("baz");
update.StreamInfo.Config.Subjects.ShouldNotContain("foo");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamInfoList server/jetstream_cluster_1_test.go:1284
// ---------------------------------------------------------------
[Fact]
public async Task Stream_names_and_list_return_all_streams()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("S1", ["s1.>"], replicas: 3);
await fx.CreateStreamAsync("S2", ["s2.>"], replicas: 3);
await fx.CreateStreamAsync("S3", ["s3.>"], replicas: 1);
var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
names.StreamNames.ShouldNotBeNull();
names.StreamNames!.Count.ShouldBe(3);
names.StreamNames.ShouldContain("S1");
names.StreamNames.ShouldContain("S2");
names.StreamNames.ShouldContain("S3");
var list = await fx.RequestAsync(JetStreamApiSubjects.StreamList, "{}");
list.StreamNames.ShouldNotBeNull();
list.StreamNames!.Count.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMaxBytesForStream server/jetstream_cluster_1_test.go:1099
// ---------------------------------------------------------------
[Fact]
public async Task Max_bytes_stream_limits_enforced_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "MAXBYTES",
Subjects = ["mb.>"],
Replicas = 3,
MaxBytes = 512,
Discard = DiscardPolicy.Old,
};
var resp = fx.CreateStreamDirect(cfg);
resp.Error.ShouldBeNull();
// Publish messages exceeding max bytes; old messages should be discarded
for (var i = 0; i < 20; i++)
await fx.PublishAsync("mb.data", new string('X', 64));
var state = await fx.GetStreamStateAsync("MAXBYTES");
// Total bytes should not exceed max_bytes by much after enforcement
((long)state.Bytes).ShouldBeLessThanOrEqualTo(cfg.MaxBytes + 128);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamPublishWithActiveConsumers server/jetstream_cluster_1_test.go:1132
// ---------------------------------------------------------------
[Fact]
public async Task Publish_with_active_consumer_delivers_messages()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACTIVE", ["active.>"], replicas: 3);
await fx.CreateConsumerAsync("ACTIVE", "durable1", filterSubject: "active.>");
for (var i = 0; i < 10; i++)
await fx.PublishAsync("active.event", $"msg-{i}");
var batch = await fx.FetchAsync("ACTIVE", "durable1", 10);
batch.Messages.Count.ShouldBe(10);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDoubleAdd server/jetstream_cluster_1_test.go:1551
// ---------------------------------------------------------------
[Fact]
public async Task Double_add_stream_with_same_config_succeeds()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var first = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3);
first.Error.ShouldBeNull();
// Adding the same stream again should succeed (idempotent)
var second = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3);
second.Error.ShouldBeNull();
second.StreamInfo!.Config.Name.ShouldBe("DUP");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamOverlapSubjects server/jetstream_cluster_1_test.go:1248
// ---------------------------------------------------------------
[Fact]
public async Task Publish_routes_to_correct_stream_among_non_overlapping()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ALPHA", ["alpha.>"], replicas: 3);
await fx.CreateStreamAsync("BETA", ["beta.>"], replicas: 3);
var ack1 = await fx.PublishAsync("alpha.one", "A");
ack1.Stream.ShouldBe("ALPHA");
var ack2 = await fx.PublishAsync("beta.one", "B");
ack2.Stream.ShouldBe("BETA");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterInterestRetention server/jetstream_cluster_1_test.go:2109
// ---------------------------------------------------------------
[Fact]
public async Task Interest_retention_stream_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "INTEREST",
Subjects = ["interest.>"],
Replicas = 3,
Retention = RetentionPolicy.Interest,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("interest.event", "msg");
var state = await fx.GetStreamStateAsync("INTEREST");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterWorkQueueRetention server/jetstream_cluster_1_test.go:2179
// ---------------------------------------------------------------
[Fact]
public async Task Work_queue_retention_removes_acked_messages_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "WQ",
Subjects = ["wq.>"],
Replicas = 2,
Retention = RetentionPolicy.WorkQueue,
MaxConsumers = 1,
};
fx.CreateStreamDirect(cfg);
await fx.CreateConsumerAsync("WQ", "worker", filterSubject: "wq.>", ackPolicy: AckPolicy.All);
await fx.PublishAsync("wq.task", "job-1");
var stateBefore = await fx.GetStreamStateAsync("WQ");
stateBefore.Messages.ShouldBe(1UL);
// Ack all up to sequence 1, triggering work queue cleanup
fx.AckAll("WQ", "worker", 1);
// Publish again to trigger runtime retention enforcement
await fx.PublishAsync("wq.task", "job-2");
var stateAfter = await fx.GetStreamStateAsync("WQ");
// After ack, only the new message should remain
stateAfter.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDeleteMsg server/jetstream_cluster_1_test.go:1748
// ---------------------------------------------------------------
[Fact]
public async Task Delete_individual_message_in_clustered_stream()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DELMSG", ["dm.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("dm.event", $"msg-{i}");
var before = await fx.GetStreamStateAsync("DELMSG");
before.Messages.ShouldBe(5UL);
// Delete message at sequence 3
var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}DELMSG", """{"seq":3}""");
del.Success.ShouldBeTrue();
var after = await fx.GetStreamStateAsync("DELMSG");
after.Messages.ShouldBe(4UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamUpdate server/jetstream_cluster_1_test.go:1433
// ---------------------------------------------------------------
[Fact]
public async Task Stream_update_preserves_existing_messages()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("UPD", ["upd.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("upd.event", $"msg-{i}");
// Update max_msgs
var update = fx.UpdateStream("UPD", ["upd.>"], replicas: 3, maxMsgs: 10);
update.Error.ShouldBeNull();
var state = await fx.GetStreamStateAsync("UPD");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94
// ---------------------------------------------------------------
[Fact]
public async Task Account_info_reports_stream_and_consumer_counts()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("AI1", ["ai1.>"], replicas: 3);
await fx.CreateStreamAsync("AI2", ["ai2.>"], replicas: 3);
await fx.CreateConsumerAsync("AI1", "c1");
var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
resp.AccountInfo.ShouldNotBeNull();
resp.AccountInfo!.Streams.ShouldBe(2);
resp.AccountInfo.Consumers.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExpand server/jetstream_cluster_1_test.go:86
// ---------------------------------------------------------------
[Fact]
public void Cluster_expand_adds_peer_to_meta_group()
{
var meta = new JetStreamMetaGroup(2);
var state = meta.GetState();
state.ClusterSize.ShouldBe(2);
// Expanding is modeled by creating a new meta group with more nodes
var expanded = new JetStreamMetaGroup(3);
expanded.GetState().ClusterSize.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMirrorAndSourceWorkQueues server/jetstream_cluster_1_test.go:2233
// ---------------------------------------------------------------
[Fact]
public async Task Mirror_stream_replicates_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
// Create origin stream
await fx.CreateStreamAsync("ORIGIN", ["origin.>"], replicas: 3);
// Create mirror stream
fx.CreateStreamDirect(new StreamConfig
{
Name = "MIRROR",
Subjects = ["mirror.>"],
Replicas = 3,
Mirror = "ORIGIN",
});
// Publish to origin
for (var i = 0; i < 5; i++)
await fx.PublishAsync("origin.event", $"mirrored-{i}");
// Mirror should have replicated messages
var mirrorState = await fx.GetStreamStateAsync("MIRROR");
mirrorState.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMirrorAndSourceInterestPolicyStream server/jetstream_cluster_1_test.go:2290
// ---------------------------------------------------------------
[Fact]
public async Task Source_stream_replicates_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
// Create source origin
await fx.CreateStreamAsync("SRC", ["src.>"], replicas: 3);
// Create aggregate stream sourcing from SRC
fx.CreateStreamDirect(new StreamConfig
{
Name = "AGG",
Subjects = ["agg.>"],
Replicas = 3,
Sources = [new StreamSourceConfig { Name = "SRC" }],
});
// Publish to source
for (var i = 0; i < 3; i++)
await fx.PublishAsync("src.event", $"sourced-{i}");
var aggState = await fx.GetStreamStateAsync("AGG");
aggState.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterUserSnapshotAndRestore server/jetstream_cluster_1_test.go:2652
// ---------------------------------------------------------------
[Fact]
public async Task Snapshot_and_restore_preserves_messages_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SNAP", ["snap.>"], replicas: 3);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("snap.event", $"msg-{i}");
// Create snapshot
var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}SNAP", "{}");
snapshot.Snapshot.ShouldNotBeNull();
snapshot.Snapshot!.Payload.ShouldNotBeNullOrEmpty();
// Purge the stream
await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SNAP", "{}");
var afterPurge = await fx.GetStreamStateAsync("SNAP");
afterPurge.Messages.ShouldBe(0UL);
// Restore from snapshot
var restore = await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}SNAP", snapshot.Snapshot.Payload);
restore.Success.ShouldBeTrue();
var afterRestore = await fx.GetStreamStateAsync("SNAP");
afterRestore.Messages.ShouldBe(10UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamSynchedTimeStamps server/jetstream_cluster_1_test.go:977
// ---------------------------------------------------------------
[Fact]
public async Task Replicated_stream_messages_have_monotonic_sequences()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SEQ", ["seq.>"], replicas: 3);
var sequences = new List<ulong>();
for (var i = 0; i < 20; i++)
{
var ack = await fx.PublishAsync("seq.event", $"msg-{i}");
sequences.Add(ack.Seq);
}
// Verify strictly monotonically increasing sequences
for (var i = 1; i < sequences.Count; i++)
sequences[i].ShouldBeGreaterThan(sequences[i - 1]);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamLimits server/jetstream_cluster_1_test.go:3248
// ---------------------------------------------------------------
[Fact]
public async Task Max_msgs_limit_enforced_in_clustered_stream()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "LIMITED",
Subjects = ["limited.>"],
Replicas = 3,
MaxMsgs = 5,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("limited.event", $"msg-{i}");
var state = await fx.GetStreamStateAsync("LIMITED");
state.Messages.ShouldBeLessThanOrEqualTo(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamInterestOnlyPolicy server/jetstream_cluster_1_test.go:3310
// ---------------------------------------------------------------
[Fact]
public async Task Interest_only_policy_stream_stores_messages_without_consumers()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "INTONLY",
Subjects = ["intonly.>"],
Replicas = 3,
Retention = RetentionPolicy.Interest,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 3; i++)
await fx.PublishAsync("intonly.data", $"msg-{i}");
// Without consumers, interest retention still stores messages
// (they are removed only when all consumers have acked)
var state = await fx.GetStreamStateAsync("INTONLY");
state.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerInfoList server/jetstream_cluster_1_test.go:1349
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_names_and_list_return_all_consumers()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CLIST", ["clist.>"], replicas: 3);
await fx.CreateConsumerAsync("CLIST", "c1");
await fx.CreateConsumerAsync("CLIST", "c2");
await fx.CreateConsumerAsync("CLIST", "c3");
var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLIST", "{}");
names.ConsumerNames.ShouldNotBeNull();
names.ConsumerNames!.Count.ShouldBe(3);
names.ConsumerNames.ShouldContain("c1");
names.ConsumerNames.ShouldContain("c2");
names.ConsumerNames.ShouldContain("c3");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDefaultMaxAckPending server/jetstream_cluster_1_test.go:1580
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_default_ack_policy_is_none()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACKDEF", ["ackdef.>"], replicas: 3);
var resp = await fx.CreateConsumerAsync("ACKDEF", "test_consumer");
resp.ConsumerInfo.ShouldNotBeNull();
resp.ConsumerInfo!.Config.AckPolicy.ShouldBe(AckPolicy.None);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExtendedStreamInfo server/jetstream_cluster_1_test.go:1878
// ---------------------------------------------------------------
[Fact]
public async Task Stream_info_returns_config_and_state()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("EXTINFO", ["ext.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("ext.event", $"msg-{i}");
var info = await fx.GetStreamInfoAsync("EXTINFO");
info.StreamInfo.ShouldNotBeNull();
info.StreamInfo!.Config.Name.ShouldBe("EXTINFO");
info.StreamInfo.Config.Replicas.ShouldBe(3);
info.StreamInfo.State.Messages.ShouldBe(5UL);
info.StreamInfo.State.FirstSeq.ShouldBe(1UL);
info.StreamInfo.State.LastSeq.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExtendedStreamInfoSingleReplica server/jetstream_cluster_1_test.go:2033
// ---------------------------------------------------------------
[Fact]
public async Task Single_replica_stream_info_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("R1INFO", ["r1info.>"], replicas: 1);
for (var i = 0; i < 3; i++)
await fx.PublishAsync("r1info.event", $"msg-{i}");
var info = await fx.GetStreamInfoAsync("R1INFO");
info.StreamInfo!.Config.Replicas.ShouldBe(1);
info.StreamInfo.State.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMultiReplicaStreams (maxmsgs_per behavior)
// ---------------------------------------------------------------
[Fact]
public async Task Max_msgs_per_subject_enforced_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "PERSUBJ",
Subjects = ["ps.>"],
Replicas = 3,
MaxMsgsPer = 2,
};
fx.CreateStreamDirect(cfg);
// Publish 5 messages to same subject; only 2 should remain
for (var i = 0; i < 5; i++)
await fx.PublishAsync("ps.topic", $"msg-{i}");
var state = await fx.GetStreamStateAsync("PERSUBJ");
state.Messages.ShouldBeLessThanOrEqualTo(2UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamExtendedUpdates server/jetstream_cluster_1_test.go:1513
// ---------------------------------------------------------------
[Fact]
public async Task Stream_update_can_change_max_msgs()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "EXTUPD",
Subjects = ["eu.>"],
Replicas = 3,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("eu.event", $"msg-{i}");
// Update to limit max_msgs
var update = fx.UpdateStream("EXTUPD", ["eu.>"], replicas: 3, maxMsgs: 5);
update.Error.ShouldBeNull();
update.StreamInfo!.Config.MaxMsgs.ShouldBe(5);
}
// ---------------------------------------------------------------
// Additional: Sealed stream rejects purge
// ---------------------------------------------------------------
[Fact]
public async Task Sealed_stream_rejects_purge_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "SEALED",
Subjects = ["sealed.>"],
Replicas = 3,
Sealed = true,
};
fx.CreateStreamDirect(cfg);
var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SEALED", "{}");
// Sealed streams should not allow purge
purge.Success.ShouldBeFalse();
}
// ---------------------------------------------------------------
// Additional: DenyDelete stream rejects message delete
// ---------------------------------------------------------------
[Fact]
public async Task DenyDelete_stream_rejects_message_delete()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "NODELDENY",
Subjects = ["nodel.>"],
Replicas = 3,
DenyDelete = true,
};
fx.CreateStreamDirect(cfg);
await fx.PublishAsync("nodel.event", "msg");
var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}NODELDENY", """{"seq":1}""");
del.Success.ShouldBeFalse();
}
}
/// <summary>
/// Self-contained fixture for JetStream cluster stream tests. Wires up
/// meta group, stream manager, consumer manager, API router, and publisher.
/// </summary>
internal sealed class ClusterStreamFixture : IAsyncDisposable
{
private readonly JetStreamMetaGroup _metaGroup;
private readonly StreamManager _streamManager;
private readonly ConsumerManager _consumerManager;
private readonly JetStreamApiRouter _router;
private readonly JetStreamPublisher _publisher;
private ClusterStreamFixture(
JetStreamMetaGroup metaGroup,
StreamManager streamManager,
ConsumerManager consumerManager,
JetStreamApiRouter router,
JetStreamPublisher publisher)
{
_metaGroup = metaGroup;
_streamManager = streamManager;
_consumerManager = consumerManager;
_router = router;
_publisher = publisher;
}
public static Task<ClusterStreamFixture> StartAsync(int nodes)
{
var meta = new JetStreamMetaGroup(nodes);
var consumerManager = new ConsumerManager(meta);
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
var publisher = new JetStreamPublisher(streamManager);
return Task.FromResult(new ClusterStreamFixture(meta, streamManager, consumerManager, router, publisher));
}
public Task<JetStreamApiResponse> CreateStreamAsync(string name, string[] subjects, int replicas, StorageType storage = StorageType.Memory)
{
var response = _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
Storage = storage,
});
return Task.FromResult(response);
}
public JetStreamApiResponse CreateStreamDirect(StreamConfig config)
=> _streamManager.CreateOrUpdate(config);
public JetStreamApiResponse UpdateStream(string name, string[] subjects, int replicas, int maxMsgs = 0)
{
return _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
MaxMsgs = maxMsgs,
});
}
public Task<PubAck> PublishAsync(string subject, string payload)
{
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
{
if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle))
{
var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult();
if (stored != null)
_consumerManager.OnPublished(ack.Stream, stored);
}
return Task.FromResult(ack);
}
throw new InvalidOperationException($"Publish to '{subject}' did not match a stream.");
}
public Task<JetStreamApiResponse> GetStreamInfoAsync(string name)
=> Task.FromResult(_streamManager.GetInfo(name));
public Task<ApiStreamState> GetStreamStateAsync(string name)
=> _streamManager.GetStateAsync(name, default).AsTask();
public string GetStoreBackendType(string name) => _streamManager.GetStoreBackendType(name);
public Task<JetStreamApiResponse> CreateConsumerAsync(
string stream,
string durableName,
string? filterSubject = null,
AckPolicy ackPolicy = AckPolicy.None)
{
var config = new ConsumerConfig
{
DurableName = durableName,
AckPolicy = ackPolicy,
};
if (!string.IsNullOrWhiteSpace(filterSubject))
config.FilterSubject = filterSubject;
return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config));
}
public Task<PullFetchBatch> FetchAsync(string stream, string durableName, int batch)
=> _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
public void AckAll(string stream, string durableName, ulong sequence)
=> _consumerManager.AckAll(stream, durableName, sequence);
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}