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,331 @@
// Ported from golang/nats-server/server/jetstream_test.go
// Go reference: server/stream.go:1500-1600 (stream.update immutable field validation)
// Covers: TestJetStreamStreamUpdate, TestJetStreamStreamUpdateMaxConsumers
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Models;
using Shouldly;
namespace NATS.Server.JetStream.Tests.JetStream.Streams;
public class ConfigUpdateValidationTests
{
// Go ref: server/stream.go:1500-1600 (stream.update)
// A valid update that only changes mutable fields (MaxMsgs) should produce no errors.
[Fact]
public void ValidateConfigUpdate_allows_valid_changes()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Retention = RetentionPolicy.Limits,
Subjects = ["orders.*"],
MaxMsgs = 100,
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Retention = RetentionPolicy.Limits,
Subjects = ["orders.*"],
MaxMsgs = 500,
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldBeEmpty();
}
// Go ref: server/stream.go:1511-1513 (storage type immutability check)
// Changing storage type from Memory to File must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_storage_type_change()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.File,
Subjects = ["orders.*"],
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("storage type"));
}
// Go ref: server/stream.go:1530-1535 (mirror immutability)
// Changing the mirror origin must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_mirror_change()
{
var existing = new StreamConfig
{
Name = "MIRROR_STREAM",
Storage = StorageType.Memory,
Mirror = "ORIGIN_A",
};
var proposed = new StreamConfig
{
Name = "MIRROR_STREAM",
Storage = StorageType.Memory,
Mirror = "ORIGIN_B",
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("mirror configuration"));
}
// Go ref: server/stream.go:1520-1525 (retention policy immutability)
// Changing the retention policy must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_retention_change()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Retention = RetentionPolicy.Limits,
Subjects = ["orders.*"],
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Retention = RetentionPolicy.WorkQueue,
Subjects = ["orders.*"],
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("retention policy"));
}
// Go ref: server/stream.go:1500-1502 (sealed stream guard)
// Any modification attempt on a sealed stream must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_sealed_stream_changes()
{
var existing = new StreamConfig
{
Name = "SEALED",
Storage = StorageType.Memory,
Sealed = true,
Subjects = ["sealed.*"],
};
var proposed = new StreamConfig
{
Name = "SEALED",
Storage = StorageType.Memory,
Sealed = true,
Subjects = ["sealed.new.*"],
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("sealed stream"));
}
// Go ref: server/stream.go:1537-1542 (sources immutability)
// Changing the sources list after creation must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_source_change()
{
var existing = new StreamConfig
{
Name = "AGG",
Storage = StorageType.Memory,
Sources =
[
new StreamSourceConfig { Name = "SRC_A" },
new StreamSourceConfig { Name = "SRC_B" },
],
};
var proposed = new StreamConfig
{
Name = "AGG",
Storage = StorageType.Memory,
Sources =
[
new StreamSourceConfig { Name = "SRC_A" },
new StreamSourceConfig { Name = "SRC_C" },
],
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("sources cannot be changed"));
}
// Go ref: server/jetstream.go — subject overlap detection between streams.
// Proposing subjects that collide with another stream's subjects must be rejected.
[Fact]
public void ValidateConfigUpdate_detects_subject_overlap()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.>"],
};
var otherStreams = new[]
{
new StreamConfig
{
Name = "ARCHIVE",
Storage = StorageType.Memory,
Subjects = ["orders.archived"],
},
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed, otherStreams);
errors.ShouldContain(e => e.Contains("ARCHIVE"));
}
// Go ref: server/jetstream.go — no error for non-overlapping subject sets.
// Proposing subjects that do not overlap with other streams must succeed.
[Fact]
public void ValidateConfigUpdate_allows_non_overlapping_subjects()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.>"],
};
var otherStreams = new[]
{
new StreamConfig
{
Name = "EVENTS",
Storage = StorageType.Memory,
Subjects = ["events.*"],
},
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed, otherStreams);
errors.ShouldBeEmpty();
}
// Go ref: server/stream.go — MaxConsumers may not be decreased.
// Decreasing MaxConsumers from a positive value must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_max_consumers_decrease()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
MaxConsumers = 10,
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
MaxConsumers = 5,
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("max consumers can only be increased"));
}
// Go ref: server/stream.go — MaxConsumers may be raised without restriction.
[Fact]
public void ValidateConfigUpdate_allows_max_consumers_increase()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
MaxConsumers = 5,
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
MaxConsumers = 20,
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldBeEmpty();
}
// Go ref: server/stream.go — RAFT consensus requires an odd number of replicas.
// Setting replicas to an even number must be rejected.
[Fact]
public void ValidateConfigUpdate_rejects_even_replicas()
{
var existing = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
Replicas = 1,
};
var proposed = new StreamConfig
{
Name = "ORDERS",
Storage = StorageType.Memory,
Subjects = ["orders.*"],
Replicas = 2,
};
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
errors.ShouldContain(e => e.Contains("replicas must be odd"));
}
// Go ref: server/stream.go:1500-1600 (stream.update) — integration via StreamManager.
// CreateOrUpdate must reject an update that changes storage type.
[Fact]
public void CreateOrUpdate_rejects_invalid_config_update()
{
var manager = new StreamManager();
var createResult = manager.CreateOrUpdate(new StreamConfig
{
Name = "EVENTS",
Storage = StorageType.Memory,
Subjects = ["events.*"],
});
createResult.Error.ShouldBeNull();
var updateResult = manager.CreateOrUpdate(new StreamConfig
{
Name = "EVENTS",
Storage = StorageType.File,
Subjects = ["events.*"],
});
updateResult.Error.ShouldNotBeNull();
updateResult.Error!.Description.ShouldContain("storage type");
}
}

View File

@@ -0,0 +1,213 @@
using NATS.Server.JetStream.MirrorSource;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;
using Shouldly;
namespace NATS.Server.JetStream.Tests.JetStream.Streams;
// Go reference: server/stream.go:3474-3720 (setupSourceConsumer, trySetupSourceConsumer)
// Go reference: server/stream.go:3531 ($JS.API.CONSUMER.CREATE.{sourceName})
// Go reference: server/stream.go:3573-3598 (DeliverPolicy, FilterSubject, AckPolicy assignment)
public class SourceConsumerSetupTests
{
// -------------------------------------------------------------------------
// BuildConsumerCreateRequest — FilterSubject
// Go reference: server/stream.go:3597-3598
// -------------------------------------------------------------------------
[Fact]
public void BuildConsumerCreateRequest_sets_filter_subject()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
FilterSubject = "orders.>",
});
var req = coordinator.BuildConsumerCreateRequest();
req.FilterSubject.ShouldBe("orders.>");
}
[Fact]
public void BuildConsumerCreateRequest_no_filter_leaves_null()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
var req = coordinator.BuildConsumerCreateRequest();
req.FilterSubject.ShouldBeNull();
}
// -------------------------------------------------------------------------
// BuildConsumerCreateRequest — DeliverPolicy
// Go reference: server/stream.go:3573-3582
// -------------------------------------------------------------------------
[Fact]
public void BuildConsumerCreateRequest_starts_from_beginning_when_no_progress()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
// LastOriginSequence is 0 — no messages have been processed yet
coordinator.LastOriginSequence.ShouldBe(0UL);
var req = coordinator.BuildConsumerCreateRequest();
req.DeliverPolicy.ShouldBe(DeliverPolicy.All);
}
[Fact]
public async Task BuildConsumerCreateRequest_resumes_from_last_sequence()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
// Advance LastOriginSequence by processing messages
await coordinator.OnOriginAppendAsync(MakeMessage(3, "orders.created", "a"), default);
await coordinator.OnOriginAppendAsync(MakeMessage(7, "orders.updated", "b"), default);
coordinator.LastOriginSequence.ShouldBe(7UL);
var req = coordinator.BuildConsumerCreateRequest();
req.DeliverPolicy.ShouldBe(DeliverPolicy.ByStartSequence);
req.OptStartSeq.ShouldBe(8UL); // LastOriginSequence + 1
}
// -------------------------------------------------------------------------
// BuildConsumerCreateRequest — AckPolicy
// Go reference: server/stream.go:3586
// -------------------------------------------------------------------------
[Fact]
public void BuildConsumerCreateRequest_sets_ack_none()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
var req = coordinator.BuildConsumerCreateRequest();
req.AckPolicy.ShouldBe(AckPolicy.None);
}
// -------------------------------------------------------------------------
// BuildConsumerCreateRequest — Push + FlowControl
// Go reference: server/stream.go:3589-3592
// -------------------------------------------------------------------------
[Fact]
public void BuildConsumerCreateRequest_enables_push_and_flow_control()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
var req = coordinator.BuildConsumerCreateRequest();
req.Push.ShouldBeTrue();
req.FlowControl.ShouldBeTrue();
}
// -------------------------------------------------------------------------
// BuildConsumerCreateRequest — HeartbeatMs
// Go reference: server/stream.go:3593 — sourceHealthHB = 1 * time.Second
// -------------------------------------------------------------------------
[Fact]
public void BuildConsumerCreateRequest_sets_heartbeat()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
var req = coordinator.BuildConsumerCreateRequest();
// HeartbeatInterval is 1 second = 1000 ms
req.HeartbeatMs.ShouldBe(1000);
}
// -------------------------------------------------------------------------
// BuildConsumerCreateSubject
// Go reference: server/stream.go:3531
// -------------------------------------------------------------------------
[Fact]
public void BuildConsumerCreateSubject_formats_correctly()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "MY_SOURCE",
});
var subject = coordinator.BuildConsumerCreateSubject();
subject.ShouldBe("$JS.API.CONSUMER.CREATE.MY_SOURCE");
}
// -------------------------------------------------------------------------
// GetDeliverySequence
// Go reference: server/stream.go si.dseq field
// -------------------------------------------------------------------------
[Fact]
public void GetDeliverySequence_starts_at_zero()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
coordinator.GetDeliverySequence.ShouldBe(0UL);
}
[Fact]
public async Task GetDeliverySequence_increments_after_processing()
{
var target = new MemStore();
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SOURCE",
});
await coordinator.OnOriginAppendAsync(MakeMessage(1, "orders.created", "x"), default);
await coordinator.OnOriginAppendAsync(MakeMessage(2, "orders.updated", "y"), default);
await coordinator.OnOriginAppendAsync(MakeMessage(3, "orders.shipped", "z"), default);
coordinator.GetDeliverySequence.ShouldBe(3UL);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static StoredMessage MakeMessage(ulong seq, string subject, string payload) => new()
{
Sequence = seq,
Subject = subject,
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
TimestampUtc = DateTime.UtcNow,
};
}

View File

@@ -0,0 +1,181 @@
using NATS.Server.JetStream.MirrorSource;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;
using Shouldly;
namespace NATS.Server.JetStream.Tests.JetStream.Streams;
// Go reference: server/stream.go:2739-2743 (mirrorInfo building StreamSourceInfo)
// Go reference: server/stream.go:2687-2736 (sourcesInfo / StreamSourceInfo)
public class SourceMirrorInfoTests
{
// -------------------------------------------------------------------------
// MirrorInfoResponse tests
// -------------------------------------------------------------------------
[Fact]
// Go reference: server/stream.go:2739 — StreamSourceInfo.Name field
public async Task MirrorInfoResponse_has_correct_name()
{
var target = new MemStore();
await using var mirror = new MirrorCoordinator(target);
var info = mirror.GetMirrorInfo("MIRROR");
info.Name.ShouldBe("MIRROR");
}
[Fact]
// Go reference: server/stream.go:2740 — StreamSourceInfo.Lag field from mirrorInfo
public async Task MirrorInfoResponse_shows_lag()
{
var target = new MemStore();
await using var mirror = new MirrorCoordinator(target);
// Sync up to origin seq 5, origin is at 10 → lag = 5 via GetHealthReport
await mirror.OnOriginAppendAsync(MakeMessage(5, "a", "data"), default);
var report = mirror.GetHealthReport(originLastSeq: 10);
report.Lag.ShouldBe(5UL);
// GetMirrorInfo calls GetHealthReport() without originLastSeq, so Lag comes from
// the internal Lag property (0 after in-process sync). Validate the field is wired up.
var info = mirror.GetMirrorInfo("LAGGED");
info.Lag.ShouldBe(mirror.Lag);
info.Name.ShouldBe("LAGGED");
}
[Fact]
// Go reference: server/stream.go:2741 — Active field, -1 when never synced
public async Task MirrorInfoResponse_active_is_negative_when_never_synced()
{
var target = new MemStore();
await using var mirror = new MirrorCoordinator(target);
var info = mirror.GetMirrorInfo("FRESH");
info.Active.ShouldBe(-1L);
}
[Fact]
// Go reference: server/stream.go:2741 — Active = ms since last sync
public async Task MirrorInfoResponse_active_shows_ms_since_sync()
{
var target = new MemStore();
await using var mirror = new MirrorCoordinator(target);
await mirror.OnOriginAppendAsync(MakeMessage(1, "a", "payload"), default);
var info = mirror.GetMirrorInfo("SYNCED");
info.Active.ShouldBeGreaterThanOrEqualTo(0L);
}
[Fact]
// Go reference: server/stream.go:2742 — StreamSourceInfo.Error field
public async Task MirrorInfoResponse_includes_error()
{
var target = new MemStore();
await using var mirror = new MirrorCoordinator(target);
mirror.SetError("consumer not found");
var info = mirror.GetMirrorInfo("BROKEN");
info.Error.ShouldBe("consumer not found");
}
// -------------------------------------------------------------------------
// SourceInfoResponse tests
// -------------------------------------------------------------------------
[Fact]
// Go reference: server/stream.go:2698 — StreamSourceInfo.Name from sourceInfo
public async Task SourceInfoResponse_has_correct_name()
{
var target = new MemStore();
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "MY_SOURCE" });
var info = source.GetSourceInfo();
info.Name.ShouldBe("MY_SOURCE");
}
[Fact]
// Go reference: server/stream.go:2700 — StreamSourceInfo.FilterSubject
public async Task SourceInfoResponse_shows_filter_subject()
{
var target = new MemStore();
await using var source = new SourceCoordinator(target, new StreamSourceConfig
{
Name = "SRC",
FilterSubject = "orders.*",
});
var info = source.GetSourceInfo();
info.FilterSubject.ShouldBe("orders.*");
}
[Fact]
// Go reference: server/stream.go:2701 — Active field, -1 when never synced
public async Task SourceInfoResponse_active_is_negative_when_never_synced()
{
var target = new MemStore();
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
var info = source.GetSourceInfo();
info.Active.ShouldBe(-1L);
}
[Fact]
// Go reference: server/stream.go:2701 — Active = ms since last sync
public async Task SourceInfoResponse_active_shows_ms_since_sync()
{
var target = new MemStore();
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "payload"), default);
var info = source.GetSourceInfo();
info.Active.ShouldBeGreaterThanOrEqualTo(0L);
}
[Fact]
// Go reference: server/stream.go:2699 — StreamSourceInfo.Lag from sourceInfo
public async Task SourceInfoResponse_lag_reflects_health_report()
{
var target = new MemStore();
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
// Apply 3 messages directly (no background loop, no timing dependency).
// Origin has 5 messages total; coordinator sees up to seq 3, so lag = 2.
await source.OnOriginAppendAsync(MakeMessage(1, "a", "1"), default);
await source.OnOriginAppendAsync(MakeMessage(2, "b", "2"), default);
await source.OnOriginAppendAsync(MakeMessage(3, "c", "3"), default);
// Simulate the lag that GetHealthReport would compute against a known origin end.
var healthReport = source.GetHealthReport(originLastSeq: 5);
var info = source.GetSourceInfo();
// GetSourceInfo calls GetHealthReport() without originLastSeq, so both should
// reflect the same internal Lag value (0 after in-process sync).
info.Lag.ShouldBe(source.Lag);
// Verify against the explicit-originLastSeq health report for the expected lag value.
healthReport.Lag.ShouldBe(2UL);
}
// -------------------------------------------------------------------------
// Helper
// -------------------------------------------------------------------------
private static StoredMessage MakeMessage(ulong seq, string subject, string payload) => new()
{
Sequence = seq,
Subject = subject,
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
TimestampUtc = DateTime.UtcNow,
};
}