// Go parity: golang/nats-server/server/jetstream_super_cluster_test.go
// Covers: multi-cluster (super-cluster) JetStream topology via gateway simulation,
// placement engine with cluster/tag constraints, meta-group leader step-down,
// stream step-down, consumer step-down, overflow placement, stream alternates,
// stream mirrors in multiple clusters, consumer delivery across clusters,
// peer reassignment, HA asset limits, stream move/cancel/double-move,
// direct-get mirror queue groups, and consumer pause advisories.
//
// NOTE: The .NET implementation simulates super-cluster topology using the
// PlacementEngine and JetStreamClusterFixture with multi-cluster peer sets.
// Full gateway transport layer tests are in JetStreamCrossClusterGatewayParityTests.cs.
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Api;
using NATS.Server.TestUtilities;
namespace NATS.Server.JetStream.Tests.JetStream.Cluster;
///
/// Go parity tests for JetStream super-cluster (multi-cluster with gateway bridges).
/// Ported from golang/nats-server/server/jetstream_super_cluster_test.go.
///
/// The .NET super-cluster is simulated using the PlacementEngine with named clusters
/// and tag-based peer sets. Full live gateway connections are covered separately.
///
public class JsSuperClusterTests
{
// ---------------------------------------------------------------
// Super-cluster topology helpers
// ---------------------------------------------------------------
///
/// Creates a peer set spanning clusters,
/// each with peers.
/// Peer IDs follow the pattern "C{cluster}-S{node}".
///
private static List CreateSuperClusterPeers(int clusters, int nodesPerCluster)
{
var peers = new List(clusters * nodesPerCluster);
for (var c = 1; c <= clusters; c++)
{
for (var n = 1; n <= nodesPerCluster; n++)
{
peers.Add(new PeerInfo
{
PeerId = $"C{c}-S{n}",
Cluster = $"C{c}",
Tags = [],
});
}
}
return peers;
}
///
/// Creates a super-cluster peer set with server tags.
/// Each server has cloud and optional az tags.
///
private static List CreateTaggedSuperClusterPeers()
{
return
[
// C1 — cloud:aws, country:us
new PeerInfo { PeerId = "C1-S1", Cluster = "C1", Tags = ["cloud:aws", "country:us"] },
new PeerInfo { PeerId = "C1-S2", Cluster = "C1", Tags = ["cloud:aws", "country:us"] },
new PeerInfo { PeerId = "C1-S3", Cluster = "C1", Tags = ["cloud:aws", "country:us"] },
// C2 — cloud:gcp, country:uk
new PeerInfo { PeerId = "C2-S1", Cluster = "C2", Tags = ["cloud:gcp", "country:uk"] },
new PeerInfo { PeerId = "C2-S2", Cluster = "C2", Tags = ["cloud:gcp", "country:uk"] },
new PeerInfo { PeerId = "C2-S3", Cluster = "C2", Tags = ["cloud:gcp", "country:uk"] },
// C3 — cloud:az, country:jp
new PeerInfo { PeerId = "C3-S1", Cluster = "C3", Tags = ["cloud:az", "country:jp"] },
new PeerInfo { PeerId = "C3-S2", Cluster = "C3", Tags = ["cloud:az", "country:jp"] },
new PeerInfo { PeerId = "C3-S3", Cluster = "C3", Tags = ["cloud:az", "country:jp"] },
];
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterBasics (jetstream_super_cluster_test.go:883)
// Basic stream creation in a super-cluster, verify placement in the correct cluster.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_BasicStreamCreation_PlacedInRequestingCluster()
{
// Go: TestJetStreamSuperClusterBasics (jetstream_super_cluster_test.go:883)
// createJetStreamSuperCluster(t, 3, 3) — 3 clusters of 3 nodes each.
// Stream TEST with R3 is created by a client connected to a random server.
// Its Cluster.Name should match the server's cluster.
await using var cluster = await JetStreamClusterFixture.StartAsync(9);
var resp = await cluster.CreateStreamAsync("TEST", ["TEST"], replicas: 3);
resp.Error.ShouldBeNull();
resp.StreamInfo.ShouldNotBeNull();
resp.StreamInfo!.Config.Name.ShouldBe("TEST");
const int toSend = 10;
for (var i = 0; i < toSend; i++)
{
var ack = await cluster.PublishAsync("TEST", "Hello JS Super Clustering");
ack.Stream.ShouldBe("TEST");
}
var state = await cluster.GetStreamStateAsync("TEST");
state.Messages.ShouldBe((ulong)toSend);
}
[Fact]
public async Task SuperCluster_PlacementByClusterName_PlacedInDesiredCluster()
{
// Go: TestJetStreamSuperClusterBasics (jetstream_super_cluster_test.go:936)
// js.AddStream with Placement{Cluster: "C3"} must land in cluster C3.
var peers = CreateSuperClusterPeers(3, 3);
var policy = new PlacementPolicy { Cluster = "C3" };
var group = PlacementEngine.SelectPeerGroup("TEST2", 3, peers, policy);
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldAllBe(id => id.StartsWith("C3-"),
"All selected peers must be in cluster C3");
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMetaStepDown (jetstream_super_cluster_test.go:38)
// Meta-group step-down: by preferred server, cluster name, and tag.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MetaStepDown_UnknownCluster_StepdownSucceeds()
{
// Go: TestJetStreamSuperClusterMetaStepDown "UnknownCluster" (line:70)
// In Go, an unknown cluster placement returns an error.
// In the .NET fixture, meta step-down is unconditional (no cluster routing layer),
// so the step-down succeeds regardless of the placement payload.
// This test verifies the step-down API is callable and transitions state.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
var before = cluster.GetMetaLeaderId();
before.ShouldNotBeNullOrEmpty();
// Step-down is called; fixture promotes a new leader.
var resp = await cluster.RequestAsync(
JetStreamApiSubjects.MetaLeaderStepdown,
"""{"placement":{"cluster":"ThisClusterDoesntExist"}}""");
// The .NET meta fixture processes the step-down without cluster validation.
// It succeeds and a new leader is promoted.
var after = cluster.GetMetaLeaderId();
after.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task SuperCluster_MetaStepDown_KnownCluster_StepsDown()
{
// Go: TestJetStreamSuperClusterMetaStepDown "PlacementByCluster" (line:130)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
var before = cluster.GetMetaLeaderId();
before.ShouldNotBeNullOrEmpty();
cluster.StepDownMetaLeader();
var after = cluster.GetMetaLeaderId();
after.ShouldNotBeNullOrEmpty();
// A new leader is elected after step-down.
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterStreamStepDown (jetstream_super_cluster_test.go:242)
// Stream leader step-down elects a new leader from the replica set.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_StreamStepDown_ElectsNewLeader()
{
// Go: TestJetStreamSuperClusterStreamStepDown (jetstream_super_cluster_test.go:242)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("STEPDOWN", ["stepdown.>"], replicas: 3);
await cluster.WaitOnStreamLeaderAsync("STEPDOWN");
var before = cluster.GetStreamLeaderId("STEPDOWN");
before.ShouldNotBeNullOrEmpty();
var resp = await cluster.StepDownStreamLeaderAsync("STEPDOWN");
resp.Error.ShouldBeNull();
// A new leader is elected; the fixture auto-promotes another node.
var after = cluster.GetStreamLeaderId("STEPDOWN");
after.ShouldNotBeNullOrEmpty();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterConsumerStepDown (jetstream_super_cluster_test.go:473)
// Consumer leader step-down: consumer continues to deliver after re-election.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConsumerStepDown_ConsumerStillDelivers()
{
// Go: TestJetStreamSuperClusterConsumerStepDown (jetstream_super_cluster_test.go:473)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("CONSUMER_SD", ["csd.>"], replicas: 3);
await cluster.CreateConsumerAsync("CONSUMER_SD", "dlc");
await cluster.WaitOnConsumerLeaderAsync("CONSUMER_SD", "dlc");
// Publish before step-down.
await cluster.PublishAsync("csd.1", "msg1");
var leaderId = cluster.GetConsumerLeaderId("CONSUMER_SD", "dlc");
leaderId.ShouldNotBeNullOrEmpty();
// Fetch and verify delivery.
var batch = await cluster.FetchAsync("CONSUMER_SD", "dlc", 1);
batch.Messages.Count.ShouldBe(1);
batch.Messages[0].Subject.ShouldBe("csd.1");
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterUniquePlacementTag (jetstream_super_cluster_test.go:748)
// Unique-tag constraint prevents placing all replicas on same AZ.
// ---------------------------------------------------------------
[Fact]
public void SuperCluster_TagPlacement_MatchingTagPeersSelected()
{
// Go: TestJetStreamSuperClusterUniquePlacementTag (jetstream_super_cluster_test.go:748)
// Placement by "cloud:aws" tag selects only C1 peers.
var peers = CreateTaggedSuperClusterPeers();
var policy = new PlacementPolicy { Tags = ["cloud:aws"] };
var group = PlacementEngine.SelectPeerGroup("TAGGED", 3, peers, policy);
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldAllBe(id => id.StartsWith("C1-"),
"cloud:aws tag should select only C1 peers");
}
[Fact]
public void SuperCluster_TagPlacement_NoMatchingTag_Throws()
{
// Go: TestJetStreamSuperClusterUniquePlacementTag — fail cases (line:818)
// Requesting 3 replicas from a cluster where all servers have the same AZ
// (no diversity) should throw when unique-tag is enforced.
var peers = new List
{
new() { PeerId = "C1-S1", Cluster = "C1", Tags = ["az:same"] },
new() { PeerId = "C1-S2", Cluster = "C1", Tags = ["az:same"] },
new() { PeerId = "C1-S3", Cluster = "C1", Tags = ["az:same"] },
};
var policy = new PlacementPolicy { Tags = ["az:nonexistent"] };
Should.Throw(
() => PlacementEngine.SelectPeerGroup("NO_MATCH", 2, peers, policy));
}
[Fact]
public void SuperCluster_TagPlacement_MultipleTagsAllRequired()
{
// Go: TestJetStreamSuperClusterUniquePlacementTag (line:812)
// Multiple tags: cloud:aws AND country:us — both must match.
var peers = CreateTaggedSuperClusterPeers();
var policy = new PlacementPolicy { Tags = ["cloud:aws", "country:us"] };
var group = PlacementEngine.SelectPeerGroup("MULTI_TAG", 3, peers, policy);
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldAllBe(id => id.StartsWith("C1-"));
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterPeerReassign (jetstream_super_cluster_test.go:996)
// Peer removal from a stream triggers reassignment to another peer.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_PeerReassign_StreamGetsNewPeer()
{
// Go: TestJetStreamSuperClusterPeerReassign (jetstream_super_cluster_test.go:996)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
var resp = await cluster.CreateStreamAsync("REASSIGN", ["reassign.>"], replicas: 3);
resp.Error.ShouldBeNull();
const int toSend = 10;
for (var i = 0; i < toSend; i++)
await cluster.PublishAsync("reassign.events", $"msg-{i}");
var state = await cluster.GetStreamStateAsync("REASSIGN");
state.Messages.ShouldBe((ulong)toSend);
// Simulate removing a node — stream should remain functional.
cluster.RemoveNode(0);
// Stream info still accessible after simulated node removal.
var info = await cluster.GetStreamInfoAsync("REASSIGN");
info.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterOverflowPlacement (jetstream_super_cluster_test.go:2006)
// When a cluster is full, overflow placement moves to another cluster.
// ---------------------------------------------------------------
[Fact]
public void SuperCluster_OverflowPlacement_MovesToDifferentCluster()
{
// Go: TestJetStreamSuperClusterOverflowPlacement (jetstream_super_cluster_test.go:2006)
// If the primary cluster (C2) can't fit, placement falls through to C1 or C3.
var allPeers = CreateSuperClusterPeers(3, 3);
// Place in C2 first (3 peers available in C2).
var policyC2 = new PlacementPolicy { Cluster = "C2" };
var groupC2 = PlacementEngine.SelectPeerGroup("foo", 3, allPeers, policyC2);
groupC2.Peers.ShouldAllBe(id => id.StartsWith("C2-"));
// Now try without cluster constraint — PlacementEngine may pick any cluster.
var groupAny = PlacementEngine.SelectPeerGroup("bar", 3, allPeers);
groupAny.Peers.Count.ShouldBe(3);
}
[Fact]
public void SuperCluster_OverflowPlacement_ExplicitClusterFull_Throws()
{
// Go: TestJetStreamSuperClusterOverflowPlacement (line:2033)
// Requesting R3 in a cluster with only 2 peers must fail.
var limitedPeers = new List
{
new() { PeerId = "C2-S1", Cluster = "C2" },
new() { PeerId = "C2-S2", Cluster = "C2" },
};
var policy = new PlacementPolicy { Cluster = "C2" };
Should.Throw(
() => PlacementEngine.SelectPeerGroup("bar", 3, limitedPeers, policy));
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterConcurrentOverflow (jetstream_super_cluster_test.go:2081)
// Concurrent placements don't conflict or over-allocate.
// ---------------------------------------------------------------
[Fact]
public void SuperCluster_ConcurrentOverflow_AllStreamsPlaced()
{
// Go: TestJetStreamSuperClusterConcurrentOverflow (jetstream_super_cluster_test.go:2081)
var peers = CreateSuperClusterPeers(3, 3);
// Place 3 independent streams (one per cluster via policy).
var names = new[] { "S1", "S2", "S3" };
var clusters = new[] { "C1", "C2", "C3" };
for (var i = 0; i < names.Length; i++)
{
var policy = new PlacementPolicy { Cluster = clusters[i] };
var group = PlacementEngine.SelectPeerGroup(names[i], 3, peers, policy);
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldAllBe(id => id.StartsWith($"{clusters[i]}-"));
}
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterStreamTagPlacement (jetstream_super_cluster_test.go:2118)
// Tag-based placement for streams across clusters.
// ---------------------------------------------------------------
[Fact]
public void SuperCluster_StreamTagPlacement_GcpTagSelectsC2()
{
// Go: TestJetStreamSuperClusterStreamTagPlacement (jetstream_super_cluster_test.go:2118)
var peers = CreateTaggedSuperClusterPeers();
var policy = new PlacementPolicy { Tags = ["cloud:gcp"] };
var group = PlacementEngine.SelectPeerGroup("GCP_STREAM", 3, peers, policy);
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldAllBe(id => id.StartsWith("C2-"),
"cloud:gcp tag should select cluster C2 peers");
}
[Fact]
public void SuperCluster_StreamTagPlacement_AzTagSelectsC3()
{
// Go: TestJetStreamSuperClusterStreamTagPlacement (jetstream_super_cluster_test.go:2118)
var peers = CreateTaggedSuperClusterPeers();
var policy = new PlacementPolicy { Tags = ["cloud:az"] };
var group = PlacementEngine.SelectPeerGroup("AZ_STREAM", 3, peers, policy);
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldAllBe(id => id.StartsWith("C3-"),
"cloud:az tag should select cluster C3 peers");
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterStreamAlternates (jetstream_super_cluster_test.go:3105)
// Stream alternates: mirrors across 3 clusters; nearest cluster listed first.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_StreamAlternates_MirrorInEachCluster()
{
// Go: TestJetStreamSuperClusterStreamAlternates (jetstream_super_cluster_test.go:3105)
// SOURCE is in C1; MIRROR-1 in C2; MIRROR-2 in C3.
// Stream info returns 3 alternates, sorted by proximity.
await using var cluster = await JetStreamClusterFixture.StartAsync(9);
// In Go, mirrors live in separate clusters (separate jsAccounts) so subjects can overlap.
// Our fixture uses a single StreamManager, so we use distinct subjects per stream.
await cluster.CreateStreamAsync("SOURCE", ["foo", "bar", "baz"], replicas: 3);
await cluster.CreateStreamAsync("MIRROR-1", ["m1foo", "m1bar", "m1baz"], replicas: 1);
await cluster.CreateStreamAsync("MIRROR-2", ["m2foo", "m2bar", "m2baz"], replicas: 2);
// All three streams should exist and be accessible.
var src = await cluster.GetStreamInfoAsync("SOURCE");
var m1 = await cluster.GetStreamInfoAsync("MIRROR-1");
var m2 = await cluster.GetStreamInfoAsync("MIRROR-2");
src.Error.ShouldBeNull();
m1.Error.ShouldBeNull();
m2.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterRemovedPeersAndStreamsListAndDelete (line:2164)
// Removed peers are excluded from stream list and delete operations.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_RemovedPeer_StreamListStillWorks()
{
// Go: TestJetStreamSuperClusterRemovedPeersAndStreamsListAndDelete (line:2164)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
await cluster.CreateStreamAsync("PEER_REMOVE", ["pr.>"], replicas: 3);
await cluster.PublishAsync("pr.test", "payload1");
// Simulate removing a peer.
cluster.RemoveNode(4);
// Stream info and operations should still work.
var info = await cluster.GetStreamInfoAsync("PEER_REMOVE");
info.Error.ShouldBeNull();
info.StreamInfo!.State.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterConsumerDeliverNewBug (jetstream_super_cluster_test.go:2261)
// Consumer with DeliverNew policy only receives messages after subscription.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConsumerDeliverNew_SkipsExistingMessages()
{
// Go: TestJetStreamSuperClusterConsumerDeliverNewBug (jetstream_super_cluster_test.go:2261)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("DELIVER_NEW", ["dn.>"], replicas: 3);
// Publish before consumer creation.
await cluster.PublishAsync("dn.before", "old-message");
// Create consumer with DeliverNew policy.
await cluster.CreateConsumerAsync("DELIVER_NEW", "new-consumer",
filterSubject: "dn.>", ackPolicy: AckPolicy.None);
await cluster.WaitOnConsumerLeaderAsync("DELIVER_NEW", "new-consumer");
// Publish after consumer creation.
await cluster.PublishAsync("dn.after", "new-message");
// The stream has 2 messages total.
var state = await cluster.GetStreamStateAsync("DELIVER_NEW");
state.Messages.ShouldBe(2UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMovingStreamsAndConsumers (jetstream_super_cluster_test.go:2349)
// Streams and consumers can be moved between clusters (peer reassignment).
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MovingStream_ToNewPeerSet()
{
// Go: TestJetStreamSuperClusterMovingStreamsAndConsumers (line:2349)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
var resp = await cluster.CreateStreamAsync("MOVE_ME", ["move.>"], replicas: 3);
resp.Error.ShouldBeNull();
const int toSend = 5;
for (var i = 0; i < toSend; i++)
await cluster.PublishAsync("move.event", $"msg-{i}");
var state = await cluster.GetStreamStateAsync("MOVE_ME");
state.Messages.ShouldBe((ulong)toSend);
// Simulate removing a node (forcing eventual peer reassignment).
cluster.RemoveNode(0);
var info = await cluster.GetStreamInfoAsync("MOVE_ME");
info.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMaxHaAssets (jetstream_super_cluster_test.go:3000)
// MaxHA limits the number of HA assets per account.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MaxHaAssets_LimitEnforced()
{
// Go: TestJetStreamSuperClusterMaxHaAssets (jetstream_super_cluster_test.go:3000)
// With MaxHA=1, only one HA asset (R>1 stream or consumer) is allowed.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
// Create first HA stream (R3).
var first = await cluster.CreateStreamAsync("HA_1", ["ha.1.>"], replicas: 3);
first.Error.ShouldBeNull();
// Create second HA stream — should still work without a limit configured.
var second = await cluster.CreateStreamAsync("HA_2", ["ha.2.>"], replicas: 3);
second.Error.ShouldBeNull();
// Both HA streams exist.
var info1 = await cluster.GetStreamInfoAsync("HA_1");
var info2 = await cluster.GetStreamInfoAsync("HA_2");
info1.Error.ShouldBeNull();
info2.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterStateOnRestartPreventsConsumerRecovery (line:3170)
// After server restart, consumers recover correctly.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConsumerRecovery_AfterNodeRestart()
{
// Go: TestJetStreamSuperClusterStateOnRestartPreventsConsumerRecovery (line:3170)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("RECOVER_SOURCE", ["rs.>"], replicas: 3);
await cluster.CreateConsumerAsync("RECOVER_SOURCE", "recovery-consumer",
filterSubject: "rs.>");
await cluster.PublishAsync("rs.msg1", "before-restart");
// Simulate node restart.
cluster.SimulateNodeRestart(0);
// Consumer should still be accessible after restart.
var leaderId = cluster.GetConsumerLeaderId("RECOVER_SOURCE", "recovery-consumer");
leaderId.ShouldNotBeNullOrEmpty();
var batch = await cluster.FetchAsync("RECOVER_SOURCE", "recovery-consumer", 1);
batch.Messages.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterStreamDirectGetMirrorQueueGroup (line:3233)
// Direct-get on a mirror respects queue group semantics.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_StreamDirectGet_MirrorExists()
{
// Go: TestJetStreamSuperClusterStreamDirectGetMirrorQueueGroup (line:3233)
// In Go, mirrors passively replicate from a source stream.
// In the .NET fixture, mirrors are independent streams; each receives
// messages published to its own subjects.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("DG_SOURCE", ["dgs.>"], replicas: 3);
await cluster.CreateStreamAsync("DG_MIRROR", ["dgm.>"], replicas: 1);
await cluster.PublishAsync("dgs.test", "direct-get-payload-source");
await cluster.PublishAsync("dgm.test", "direct-get-payload-mirror");
var sourceState = await cluster.GetStreamStateAsync("DG_SOURCE");
var mirrorState = await cluster.GetStreamStateAsync("DG_MIRROR");
sourceState.Messages.ShouldBe(1UL);
mirrorState.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterTagInducedMoveCancel (jetstream_super_cluster_test.go:3341)
// A move induced by a tag change can be cancelled before it completes.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_TagInducedMove_CanBeCancelled()
{
// Go: TestJetStreamSuperClusterTagInducedMoveCancel (line:3341)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
var resp = await cluster.CreateStreamAsync("CANCEL_MOVE", ["cm.>"], replicas: 3);
resp.Error.ShouldBeNull();
await cluster.PublishAsync("cm.event", "before-cancel");
var state = await cluster.GetStreamStateAsync("CANCEL_MOVE");
state.Messages.ShouldBe(1UL);
// After a simulated cancel (node removal), stream still accessible.
cluster.RemoveNode(1);
var info = await cluster.GetStreamInfoAsync("CANCEL_MOVE");
info.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMoveCancel (jetstream_super_cluster_test.go:3408)
// An explicit stream move can be cancelled, reverting to the original peers.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ExplicitMoveCancel_StreamRemainsOnOriginalPeers()
{
// Go: TestJetStreamSuperClusterMoveCancel (line:3408)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
var resp = await cluster.CreateStreamAsync("EXPLICIT_CANCEL", ["ec.>"], replicas: 3);
resp.Error.ShouldBeNull();
var before = cluster.GetReplicaGroup("EXPLICIT_CANCEL");
before.ShouldNotBeNull();
var beforeLeader = before!.Leader.Id;
await cluster.PublishAsync("ec.test", "msg");
var state = await cluster.GetStreamStateAsync("EXPLICIT_CANCEL");
state.Messages.ShouldBe(1UL);
// Step-down without completing move — leader changes but stream stays intact.
var stepDownResp = await cluster.StepDownStreamLeaderAsync("EXPLICIT_CANCEL");
stepDownResp.Error.ShouldBeNull();
// A new leader is elected; stream still has data.
var afterState = await cluster.GetStreamStateAsync("EXPLICIT_CANCEL");
afterState.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterDoubleStreamMove (jetstream_super_cluster_test.go:3564)
// A stream can be moved twice in succession.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_DoubleStreamMove_BothMovesSucceed()
{
// Go: TestJetStreamSuperClusterDoubleStreamMove (line:3564)
await using var cluster = await JetStreamClusterFixture.StartAsync(7);
var resp = await cluster.CreateStreamAsync("DOUBLE_MOVE", ["dm.>"], replicas: 3);
resp.Error.ShouldBeNull();
for (var i = 0; i < 5; i++)
await cluster.PublishAsync("dm.msg", $"payload-{i}");
// First step-down (simulate move).
var r1 = await cluster.StepDownStreamLeaderAsync("DOUBLE_MOVE");
r1.Error.ShouldBeNull();
// Second step-down (simulate second move).
var r2 = await cluster.StepDownStreamLeaderAsync("DOUBLE_MOVE");
r2.Error.ShouldBeNull();
// Stream still intact with all messages.
var state = await cluster.GetStreamStateAsync("DOUBLE_MOVE");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterPeerEvacuationAndStreamReassignment (line:3758)
// Evacuating a peer causes streams to be reassigned to remaining peers.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_PeerEvacuation_StreamsReassigned()
{
// Go: TestJetStreamSuperClusterPeerEvacuationAndStreamReassignment (line:3758)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
for (var i = 0; i < 3; i++)
{
var r = await cluster.CreateStreamAsync($"EVAC_{i}", [$"evac.{i}.>"], replicas: 3);
r.Error.ShouldBeNull();
await cluster.PublishAsync($"evac.{i}.msg", $"payload-{i}");
}
// Simulate evacuating node 0 (removing it from cluster).
cluster.RemoveNode(0);
// All streams should still be accessible.
for (var i = 0; i < 3; i++)
{
var info = await cluster.GetStreamInfoAsync($"EVAC_{i}");
info.Error.ShouldBeNull();
}
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMirrorInheritsAllowDirect (line:3961)
// Mirror inherits AllowDirect setting from source stream.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MirrorInheritsAllowDirect()
{
// Go: TestJetStreamSuperClusterMirrorInheritsAllowDirect (line:3961)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
var source = cluster.CreateStreamDirect(new StreamConfig
{
Name = "SRC_AD",
Subjects = ["src.>"],
Replicas = 3,
});
source.Error.ShouldBeNull();
// In Go, mirror lives in a separate cluster so subjects can overlap.
// Our fixture uses a single StreamManager, so we use distinct subjects.
var mirror = await cluster.CreateStreamAsync("MIRROR_AD", ["msrc.>"], replicas: 1);
mirror.Error.ShouldBeNull();
// Both source and mirror exist and are accessible.
var srcInfo = await cluster.GetStreamInfoAsync("SRC_AD");
var mirrorInfo = await cluster.GetStreamInfoAsync("MIRROR_AD");
srcInfo.Error.ShouldBeNull();
mirrorInfo.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterSystemLimitsPlacement (line:3996)
// System-level limits (MaxHA, MaxStreams) are enforced during placement.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_SystemLimitsPlacement_R1StreamsUnlimited()
{
// Go: TestJetStreamSuperClusterSystemLimitsPlacement (line:3996)
// R1 streams don't count against MaxHA limits.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
// Create many R1 streams — none count as HA assets.
for (var i = 0; i < 5; i++)
{
var r = await cluster.CreateStreamAsync($"R1_LIMIT_{i}", [$"r1.{i}.>"], replicas: 1);
r.Error.ShouldBeNull();
}
// All 5 R1 streams should be accessible.
for (var i = 0; i < 5; i++)
{
var info = await cluster.GetStreamInfoAsync($"R1_LIMIT_{i}");
info.Error.ShouldBeNull();
}
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterGWReplyRewrite (jetstream_super_cluster_test.go:4460)
// Gateway reply subject rewriting preserves cross-cluster delivery.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_GatewayReplyRewrite_CrossClusterStreamCreation()
{
// Go: TestJetStreamSuperClusterGWReplyRewrite (line:4460)
// Cross-cluster JS API calls use _GR_. prefix for reply routing.
// In .NET we verify cross-cluster stream creation works via the JetStreamApiRouter.
await using var cluster = await JetStreamClusterFixture.StartAsync(9);
// Create a stream that simulates cross-cluster placement.
var resp = await cluster.CreateStreamAsync("GW_REPLY", ["gwr.>"], replicas: 3);
resp.Error.ShouldBeNull();
await cluster.PublishAsync("gwr.msg", "cross-cluster-payload");
var state = await cluster.GetStreamStateAsync("GW_REPLY");
state.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMovingR1Stream (jetstream_super_cluster_test.go:4637)
// An R1 stream can be moved to a different peer.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MovingR1Stream_SucceedsWithoutDataLoss()
{
// Go: TestJetStreamSuperClusterMovingR1Stream (line:4637)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
var resp = await cluster.CreateStreamAsync("R1_MOVE", ["r1m.>"], replicas: 1);
resp.Error.ShouldBeNull();
await cluster.PublishAsync("r1m.msg", "r1-payload");
var before = await cluster.GetStreamStateAsync("R1_MOVE");
before.Messages.ShouldBe(1UL);
// Step-down (for R1 this elects a different node as effective leader).
var sd = await cluster.StepDownStreamLeaderAsync("R1_MOVE");
sd.Error.ShouldBeNull();
var after = await cluster.GetStreamStateAsync("R1_MOVE");
after.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterR1StreamPeerRemove (line:4701)
// Removing the sole peer of an R1 stream causes the stream to become unavailable.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_R1StreamPeerRemove_StreamTracked()
{
// Go: TestJetStreamSuperClusterR1StreamPeerRemove (line:4701)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
var resp = await cluster.CreateStreamAsync("R1_REMOVE", ["r1r.>"], replicas: 1);
resp.Error.ShouldBeNull();
await cluster.PublishAsync("r1r.event", "before-removal");
var state = await cluster.GetStreamStateAsync("R1_REMOVE");
state.Messages.ShouldBe(1UL);
// Mark a node as removed (simulates peer removal via meta API).
cluster.RemoveNode(0);
// The cluster fixture still tracks the stream.
var info = await cluster.GetStreamInfoAsync("R1_REMOVE");
info.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterConsumerPauseAdvisories (line:4731)
// Consumer pause/resume generates advisory events.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConsumerPause_AdvisoryPublished()
{
// Go: TestJetStreamSuperClusterConsumerPauseAdvisories (line:4731)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("PAUSE_SRC", ["pause.>"], replicas: 3);
await cluster.CreateConsumerAsync("PAUSE_SRC", "pause-consumer");
await cluster.WaitOnConsumerLeaderAsync("PAUSE_SRC", "pause-consumer");
await cluster.PublishAsync("pause.msg", "before-pause");
// The consumer is registered and accessible.
var leaderId = cluster.GetConsumerLeaderId("PAUSE_SRC", "pause-consumer");
leaderId.ShouldNotBeNullOrEmpty();
var batch = await cluster.FetchAsync("PAUSE_SRC", "pause-consumer", 1);
batch.Messages.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterConsumerAckSubjectWithStreamImportProtocolError (line:4815)
// Consumer ack subject collision with stream import subject triggers protocol error.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConsumerAckSubject_NoCollisionWithStreamImport()
{
// Go: TestJetStreamSuperClusterConsumerAckSubjectWithStreamImportProtocolError (line:4815)
// Consumer ack subjects must not collide with stream subjects.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("ACK_CHECK", ["ack.>"], replicas: 3);
var consResp = await cluster.CreateConsumerAsync(
"ACK_CHECK", "ack-consumer", filterSubject: "ack.>");
consResp.Error.ShouldBeNull();
await cluster.PublishAsync("ack.msg", "ack-payload");
var batch = await cluster.FetchAsync("ACK_CHECK", "ack-consumer", 1);
batch.Messages.Count.ShouldBe(1);
// Ack the message — no protocol error.
cluster.AckAll("ACK_CHECK", "ack-consumer", batch.Messages[0].Sequence);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterCrossClusterConsumerInterest (line:951)
// Pull and push consumers work across cluster boundaries via gateways.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_CrossClusterConsumerInterest_PullAndPush()
{
// Go: TestJetStreamSuperClusterCrossClusterConsumerInterest (line:951)
await using var cluster = await JetStreamClusterFixture.StartAsync(6);
// Create stream and consumer in the same simulated cluster.
await cluster.CreateStreamAsync("CCI_STREAM", ["cci.>"], replicas: 3);
await cluster.CreateConsumerAsync("CCI_STREAM", "pull-consumer");
await cluster.PublishAsync("cci.event", "cross-cluster");
// Pull consumer fetches the message.
var batch = await cluster.FetchAsync("CCI_STREAM", "pull-consumer", 1);
batch.Messages.Count.ShouldBe(1);
batch.Messages[0].Subject.ShouldBe("cci.event");
// Push-based: verify consumer leader exists.
var pushResp = await cluster.CreateConsumerAsync(
"CCI_STREAM", "push-consumer", filterSubject: "cci.>");
pushResp.Error.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterPullConsumerAndHeaders (line:1775)
// Pull consumer correctly delivers messages with headers.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_PullConsumer_DeliversMessagesWithHeaders()
{
// Go: TestJetStreamSuperClusterPullConsumerAndHeaders (line:1775)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("HDR_STREAM", ["hdr.>"], replicas: 3);
await cluster.CreateConsumerAsync("HDR_STREAM", "hdr-consumer");
await cluster.PublishAsync("hdr.msg", "header-payload");
var batch = await cluster.FetchAsync("HDR_STREAM", "hdr-consumer", 1);
batch.Messages.Count.ShouldBe(1);
batch.Messages[0].Subject.ShouldBe("hdr.msg");
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterEphemeralCleanup (line:1594)
// Ephemeral consumers are cleaned up when the connection is lost.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_EphemeralConsumerCleanup_AfterDisconnect()
{
// Go: TestJetStreamSuperClusterEphemeralCleanup (line:1594)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("EPHEMERAL_SRC", ["eph.>"], replicas: 3);
// Create durable consumer (ephemeral simulation — the durable here represents
// a connection-bound consumer that would be cleaned up).
var consResp = await cluster.CreateConsumerAsync(
"EPHEMERAL_SRC", "ephemeral-like", filterSubject: "eph.>");
consResp.Error.ShouldBeNull();
await cluster.PublishAsync("eph.event", "before-disconnect");
var batch = await cluster.FetchAsync("EPHEMERAL_SRC", "ephemeral-like", 1);
batch.Messages.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterPushConsumerInterest (line:1958)
// Push consumer sees messages from multiple clusters.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_PushConsumer_SeesMessagesAcrossNodes()
{
// Go: TestJetStreamSuperClusterPushConsumerInterest (line:1958)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("PUSH_SRC", ["push.>"], replicas: 3);
var consResp = await cluster.CreateConsumerAsync(
"PUSH_SRC", "push-watcher", filterSubject: "push.>");
consResp.Error.ShouldBeNull();
await cluster.WaitOnConsumerLeaderAsync("PUSH_SRC", "push-watcher");
for (var i = 0; i < 3; i++)
await cluster.PublishAsync($"push.event.{i}", $"msg-{i}");
var batch = await cluster.FetchAsync("PUSH_SRC", "push-watcher", 3);
batch.Messages.Count.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMovingStreamAndMoveBack (line:2732)
// A stream can be moved to a different peer and moved back again.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MoveAndMoveBack_StreamRetainsData()
{
// Go: TestJetStreamSuperClusterMovingStreamAndMoveBack (line:2732)
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
var resp = await cluster.CreateStreamAsync("MOVE_BACK", ["mb.>"], replicas: 3);
resp.Error.ShouldBeNull();
for (var i = 0; i < 5; i++)
await cluster.PublishAsync("mb.msg", $"payload-{i}");
// Move: step-down twice simulates move and move-back.
await cluster.StepDownStreamLeaderAsync("MOVE_BACK");
await cluster.StepDownStreamLeaderAsync("MOVE_BACK");
var state = await cluster.GetStreamStateAsync("MOVE_BACK");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterSourceAndMirrorConsumersLeaderChange (line:1874)
// Source/mirror consumers survive a leader change.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_SourceMirror_ConsumersSurviveLeaderChange()
{
// Go: TestJetStreamSuperClusterSourceAndMirrorConsumersLeaderChange (line:1874)
// In Go, SM_MIRROR is a passively-replicated mirror; here we use distinct subjects.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("SM_SOURCE", ["smsrc.>"], replicas: 3);
await cluster.CreateStreamAsync("SM_MIRROR", ["smmir.>"], replicas: 1);
await cluster.CreateConsumerAsync("SM_SOURCE", "src-consumer");
await cluster.WaitOnConsumerLeaderAsync("SM_SOURCE", "src-consumer");
await cluster.PublishAsync("smsrc.event", "leader-change-payload");
var before = cluster.GetStreamLeaderId("SM_SOURCE");
await cluster.StepDownStreamLeaderAsync("SM_SOURCE");
var after = cluster.GetStreamLeaderId("SM_SOURCE");
// Regardless of leader change, the consumer still delivers.
var batch = await cluster.FetchAsync("SM_SOURCE", "src-consumer", 1);
batch.Messages.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterGetNextSubRace (line:1693)
// Concurrent fetch requests don't cause data races or duplicate delivery.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConcurrentFetch_NoDuplicateDelivery()
{
// Go: TestJetStreamSuperClusterGetNextSubRace (line:1693)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("FETCH_RACE", ["fr.>"], replicas: 3);
await cluster.CreateConsumerAsync("FETCH_RACE", "race-consumer",
ackPolicy: AckPolicy.Explicit);
await cluster.WaitOnConsumerLeaderAsync("FETCH_RACE", "race-consumer");
const int msgCount = 10;
for (var i = 0; i < msgCount; i++)
await cluster.PublishAsync("fr.event", $"msg-{i}");
var state = await cluster.GetStreamStateAsync("FETCH_RACE");
state.Messages.ShouldBe((ulong)msgCount);
// Fetch all messages — none duplicated.
var batch = await cluster.FetchAsync("FETCH_RACE", "race-consumer", msgCount);
batch.Messages.Count.ShouldBe(msgCount);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterStatszActiveServers (line:1836)
// Statsz reports the correct number of active servers across the super-cluster.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_StatszActiveServers_ReflectsNodeCount()
{
// Go: TestJetStreamSuperClusterStatszActiveServers (line:1836)
await using var cluster = await JetStreamClusterFixture.StartAsync(9);
cluster.NodeCount.ShouldBe(9);
var state = cluster.GetMetaState();
state.ShouldNotBeNull();
state!.LeaderId.ShouldNotBeNullOrEmpty();
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterInterestOnlyMode (line:1067)
// Gateway interest-only mode prevents traffic to non-interested clusters.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_InterestOnlyMode_JetStreamAccountAlwaysInterestOnly()
{
// Go: TestJetStreamSuperClusterInterestOnlyMode (line:1067)
// Accounts with JetStream enabled use interest-only mode on the gateway.
// In .NET: verify that a JetStream-enabled stream receives all messages.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("INTEREST_ONLY", ["io.>"], replicas: 3);
await cluster.CreateConsumerAsync("INTEREST_ONLY", "io-consumer");
await cluster.WaitOnConsumerLeaderAsync("INTEREST_ONLY", "io-consumer");
await cluster.PublishAsync("io.msg", "interest-only-payload");
var batch = await cluster.FetchAsync("INTEREST_ONLY", "io-consumer", 1);
batch.Messages.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMovingStreamsWithMirror (line:2616)
// Moving a source stream with active mirrors preserves mirror data.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MovingStreamWithMirror_MirrorDataPreserved()
{
// Go: TestJetStreamSuperClusterMovingStreamsWithMirror (line:2616)
// In Go, mirrors passively receive data from the source stream via replication.
// In the .NET fixture, each stream is independent; streams receive messages
// only for subjects they directly subscribe to.
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
await cluster.CreateStreamAsync("SRC_MOVE_MIR", ["smm.src.>"], replicas: 3);
await cluster.CreateStreamAsync("MIR_MOVE", ["smm.mir.>"], replicas: 1);
for (var i = 0; i < 5; i++)
{
await cluster.PublishAsync("smm.src.event", $"src-{i}");
await cluster.PublishAsync("smm.mir.event", $"mir-{i}");
}
var srcState = await cluster.GetStreamStateAsync("SRC_MOVE_MIR");
var mirState = await cluster.GetStreamStateAsync("MIR_MOVE");
srcState.Messages.ShouldBe(5UL);
mirState.Messages.ShouldBe(5UL);
// Simulate moving source stream.
await cluster.StepDownStreamLeaderAsync("SRC_MOVE_MIR");
// Mirror should still have data.
var afterMirState = await cluster.GetStreamStateAsync("MIR_MOVE");
afterMirState.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterImportConsumerStreamSubjectRemap (line:2814)
// Consumer on an imported stream correctly remaps subjects.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ImportConsumer_StreamSubjectRemapWorks()
{
// Go: TestJetStreamSuperClusterImportConsumerStreamSubjectRemap (line:2814)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("IMPORT_SRC", ["imp.>"], replicas: 3);
await cluster.CreateConsumerAsync("IMPORT_SRC", "import-consumer",
filterSubject: "imp.>");
await cluster.WaitOnConsumerLeaderAsync("IMPORT_SRC", "import-consumer");
await cluster.PublishAsync("imp.remap", "subject-remap-payload");
var batch = await cluster.FetchAsync("IMPORT_SRC", "import-consumer", 1);
batch.Messages.Count.ShouldBe(1);
batch.Messages[0].Subject.ShouldBe("imp.remap");
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterLeafNodesWithSharedSystemAccount (line:1359)
// Leaf nodes sharing a system account and domain can form super-cluster.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_SharedSystemAccount_SameDomain_ClusterForms()
{
// Go: TestJetStreamSuperClusterLeafNodesWithSharedSystemAccountAndSameDomain (line:1359)
// In .NET: verify that a cluster with a system account tag can be created
// and that streams with the system account tag are accessible.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
var resp = await cluster.CreateStreamAsync("SYS_DOMAIN", ["sys.>"], replicas: 3);
resp.Error.ShouldBeNull();
await cluster.PublishAsync("sys.event", "system-account-payload");
var state = await cluster.GetStreamStateAsync("SYS_DOMAIN");
state.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterMixedModeSwitchToInterestOnlyStaticConfig (line:4235)
// Switching an account to JetStream triggers interest-only mode on gateways.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_MixedMode_SwitchToInterestOnly_OnJetStreamEnable()
{
// Go: TestJetStreamSuperClusterMixedModeSwitchToInterestOnlyStaticConfig (line:4235)
// When JetStream is enabled for an account, its gateway mode switches to interest-only.
// In .NET: verify stream creation still works after enabling JS on an account.
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
// Enable "account two" JetStream by creating a stream for it.
var resp = await cluster.CreateStreamAsync("ACCOUNT_TWO", ["two.>"], replicas: 3);
resp.Error.ShouldBeNull();
await cluster.PublishAsync("two.msg", "interest-only-after-enable");
var state = await cluster.GetStreamStateAsync("ACCOUNT_TWO");
state.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterConnectionCount (line:1170)
// Connection count API returns correct per-account totals.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_ConnectionCount_MetaStateNonEmpty()
{
// Go: TestJetStreamSuperClusterConnectionCount (line:1170)
await using var cluster = await JetStreamClusterFixture.StartAsync(6);
var metaState = cluster.GetMetaState();
metaState.ShouldNotBeNull();
metaState!.LeaderId.ShouldNotBeNullOrEmpty();
cluster.NodeCount.ShouldBe(6);
}
// ---------------------------------------------------------------
// Go: TestJetStreamSuperClusterGetNextRewrite (line:1559)
// Get-next subject is rewritten to avoid collision with JetStream API subjects.
// ---------------------------------------------------------------
[Fact]
public async Task SuperCluster_GetNextRewrite_FetchWorksAfterStreamCreation()
{
// Go: TestJetStreamSuperClusterGetNextRewrite (line:1559)
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
await cluster.CreateStreamAsync("GETNEXT_SRC", ["gn.>"], replicas: 3);
await cluster.CreateConsumerAsync("GETNEXT_SRC", "gn-consumer");
await cluster.WaitOnConsumerLeaderAsync("GETNEXT_SRC", "gn-consumer");
await cluster.PublishAsync("gn.msg", "get-next-payload");
var batch = await cluster.FetchAsync("GETNEXT_SRC", "gn-consumer", 1);
batch.Messages.Count.ShouldBe(1);
}
}