feat(p7-09): JetStream unit tests — versioning (12), dirstore (12), batching/errors deferred (66)

Port session P7-09: add tests from jetstream_versioning_test.go (T:1791–1808),
dirstore_test.go (T:285–296), jetstream_batching_test.go (T:716–744),
jetstream_errors_test.go (T:1381–1384), and accounts_test.go (T:80–110).

- JetStreamVersioningTests: 12 active unit tests + 6 deferred (server-required)
- DirectoryStoreTests: 12 filesystem tests using fake JWTs (no NKeys dependency)
- JetStreamBatchingTests: 29 deferred stubs (all require running JetStream cluster)
- JetStreamErrorsTests: 4 deferred stubs (NewJS* factories not yet ported)
- accounts_test.go T:80–110: 31 deferred (all use RunServerWithConfig)

Fix DirJwtStore.cs expiration bugs:
  - Use DateTimeOffset.UtcNow.UtcTicks (not Unix-relative ticks) for expiry comparison
  - Replace in-place JwtItem mutation with new-object replacement so DrainStale
    can detect stale heap entries via ReferenceEquals check

Add JetStreamVersioning.cs methods: SetStaticStreamMetadata,
SetDynamicStreamMetadata, CopyStreamMetadata, SetStaticConsumerMetadata,
SetDynamicConsumerMetadata, SetDynamicConsumerInfoMetadata, CopyConsumerMetadata.

Tests: 725 pass, 53 skipped/deferred, 0 failures.
DB: +24 complete, +66 deferred.
This commit is contained in:
Joseph Doherty
2026-02-26 20:02:00 -05:00
parent 6e90eea736
commit f0faaffe69
9 changed files with 1627 additions and 18 deletions

View File

@@ -0,0 +1,770 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Mirrors server/dirstore_test.go tests 285296 in the NATS server Go source.
// The Go tests use nkeys.CreateAccount() + jwt.NewAccountClaims() to generate
// real signed JWTs. Here we craft minimal fake JWT strings directly using
// Base64URL-encoded JSON payloads, since DirJwtStore only parses the "exp",
// "iat" and "jti" numeric/string claims from the payload.
using System.Security.Cryptography;
using System.Text;
using Shouldly;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
/// <summary>
/// Unit tests for <see cref="DirJwtStore"/> expiration, limits, LRU eviction,
/// reload, TTL and notification behaviour.
/// Mirrors server/dirstore_test.go tests 285296.
/// </summary>
[Collection("DirectoryStoreTests")]
public sealed class DirectoryStoreTests : IDisposable
{
// -------------------------------------------------------------------------
// Counter for unique public-key names
// -------------------------------------------------------------------------
private static int _counter;
private static string NextKey() =>
$"ACCT{Interlocked.Increment(ref _counter):D8}";
// -------------------------------------------------------------------------
// Temp directory management
// -------------------------------------------------------------------------
private readonly List<string> _tempDirs = [];
private string MakeTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dirstore_" + Path.GetRandomFileName());
Directory.CreateDirectory(dir);
_tempDirs.Add(dir);
return dir;
}
public void Dispose()
{
foreach (var dir in _tempDirs)
try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
}
// -------------------------------------------------------------------------
// Helpers — fake JWT construction
// -------------------------------------------------------------------------
/// <summary>
/// Builds a minimal fake JWT string: header.payload.signature
/// where the payload contains "exp", "iat" and "jti" claims.
/// </summary>
private static string MakeFakeJwt(
long expUnixSeconds,
long iatUnixSeconds = 0,
string? jti = null)
{
if (iatUnixSeconds == 0)
iatUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
jti ??= Guid.NewGuid().ToString("N");
var payloadObj = expUnixSeconds > 0
? $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds},\"exp\":{expUnixSeconds}}}"
: $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds}}}";
var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"ed25519-nkey\",\"typ\":\"JWT\"}"));
var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadObj));
var sigB64 = Base64UrlEncode(new byte[64]); // dummy 64-byte signature
return $"{headerB64}.{payloadB64}.{sigB64}";
}
/// <summary>
/// Rounds a <see cref="DateTimeOffset"/> to the nearest whole second,
/// mirroring Go's <c>time.Now().Round(time.Second)</c>.
/// </summary>
private static DateTimeOffset RoundToSecond(DateTimeOffset dt) =>
dt.Millisecond >= 500
? new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset).AddSeconds(1)
: new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Creates and saves a test account JWT in the store.
/// <paramref name="expSec"/> == 0 means no expiration.
/// Returns the saved JWT string.
/// </summary>
private static string CreateTestAccount(DirJwtStore store, string pubKey, int expSec)
{
long exp = expSec > 0
// Round to the nearest second first (mirrors Go's time.Now().Round(time.Second).Add(...).Unix()),
// ensuring the expiry is at a whole-second boundary and avoiding sub-second truncation races.
? RoundToSecond(DateTimeOffset.UtcNow).AddSeconds(expSec).ToUnixTimeSeconds()
: 0;
var theJwt = MakeFakeJwt(exp);
store.SaveAcc(pubKey, theJwt);
return theJwt;
}
/// <summary>
/// Counts non-deleted .jwt files in <paramref name="dir"/> recursively.
/// </summary>
private static int CountJwtFiles(string dir) =>
Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
// -------------------------------------------------------------------------
// T:285 — TestExpiration
// -------------------------------------------------------------------------
[Fact] // T:285
public async Task Expiration_ExpiredAccountIsRemovedByBackground()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(50),
limit: 10,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
var hBegin = store.Hash();
// Add one account that should NOT expire (100-second TTL).
var keyNoExp = NextKey();
CreateTestAccount(store, keyNoExp, 100);
var hNoExp = store.Hash();
hNoExp.ShouldNotBe(hBegin);
// Add one account that should expire in ~1 second.
var keyExp = NextKey();
CreateTestAccount(store, keyExp, 1);
CountJwtFiles(dir).ShouldBe(2);
// Wait up to 4 s for the expired file to vanish.
var deadline = DateTime.UtcNow.AddSeconds(4);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(100);
if (CountJwtFiles(dir) == 1)
break;
}
CountJwtFiles(dir).ShouldBe(1, "expired account should be removed");
// Hash after expiry should equal hash after adding only the non-expiring key.
var lh = store.Hash();
lh.ShouldBe(hNoExp);
}
// -------------------------------------------------------------------------
// T:286 — TestLimit
// -------------------------------------------------------------------------
[Fact] // T:286
public void Limit_LruEvictsOldestEntries()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(100),
limit: 5,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
var h = store.Hash();
// Update the first account 10 times — should remain as 1 entry.
var firstKey = NextKey();
for (var i = 0; i < 10; i++)
{
CreateTestAccount(store, firstKey, 50);
CountJwtFiles(dir).ShouldBe(1);
}
// Add 10 more new accounts — limit is 5, LRU eviction kicks in.
for (var i = 0; i < 10; i++)
{
var k = NextKey();
CreateTestAccount(store, k, i + 1); // short but non-zero expiry
var nh = store.Hash();
nh.ShouldNotBe(h);
h = nh;
}
// After all adds, only 5 files should remain.
CountJwtFiles(dir).ShouldBe(5);
// The first account should have been evicted.
File.Exists(Path.Combine(dir, firstKey + ".jwt")).ShouldBeFalse();
// Updating the first account again should succeed (limit allows eviction).
for (var i = 0; i < 10; i++)
{
CreateTestAccount(store, firstKey, 50);
CountJwtFiles(dir).ShouldBe(5);
}
}
// -------------------------------------------------------------------------
// T:287 — TestLimitNoEvict
// -------------------------------------------------------------------------
[Fact] // T:287
public async Task LimitNoEvict_StoreFullThrowsOnNewKey()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(50),
limit: 2,
evictOnLimit: false,
ttl: TimeSpan.Zero,
changeNotification: null);
var key1 = NextKey();
var key2 = NextKey();
var key3 = NextKey();
CreateTestAccount(store, key1, 100);
CountJwtFiles(dir).ShouldBe(1);
// key2 expires in 1 second
CreateTestAccount(store, key2, 1);
CountJwtFiles(dir).ShouldBe(2);
var hashBefore = store.Hash();
// Attempting to add key3 should throw (limit=2, no evict).
var exp3 = DateTimeOffset.UtcNow.AddSeconds(100).ToUnixTimeSeconds();
var jwt3 = MakeFakeJwt(exp3);
Should.Throw<InvalidOperationException>(() => store.SaveAcc(key3, jwt3));
CountJwtFiles(dir).ShouldBe(2);
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
// Hash should not change after the failed add.
store.Hash().ShouldBe(hashBefore);
// Wait for key2 to expire.
await Task.Delay(2200);
// Now adding key3 should succeed.
store.SaveAcc(key3, jwt3);
CountJwtFiles(dir).ShouldBe(2);
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
}
// -------------------------------------------------------------------------
// T:288 — TestLruLoad
// -------------------------------------------------------------------------
[Fact] // T:288
public void LruLoad_LoadReordersLru()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(100),
limit: 2,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
var key1 = NextKey();
var key2 = NextKey();
var key3 = NextKey();
CreateTestAccount(store, key1, 10);
CountJwtFiles(dir).ShouldBe(1);
CreateTestAccount(store, key2, 10);
CountJwtFiles(dir).ShouldBe(2);
// Access key1 — makes it the most-recently-used.
store.LoadAcc(key1);
// Adding key3 should evict key2 (oldest), not key1.
CreateTestAccount(store, key3, 10);
CountJwtFiles(dir).ShouldBe(2);
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
}
// -------------------------------------------------------------------------
// T:289 — TestLruVolume
// -------------------------------------------------------------------------
[Fact] // T:289
public void LruVolume_ContinuousReplacementsAlwaysEvictsOldest()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(50),
limit: 2,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
const int ReplaceCnt = 200; // must be > 2 due to the invariant
var keys = new string[ReplaceCnt];
keys[0] = NextKey();
CreateTestAccount(store, keys[0], 10000);
CountJwtFiles(dir).ShouldBe(1);
keys[1] = NextKey();
CreateTestAccount(store, keys[1], 10000);
CountJwtFiles(dir).ShouldBe(2);
for (var i = 2; i < ReplaceCnt; i++)
{
keys[i] = NextKey();
CreateTestAccount(store, keys[i], 10000);
CountJwtFiles(dir).ShouldBe(2);
// key two positions back should have been evicted.
File.Exists(Path.Combine(dir, keys[i - 2] + ".jwt")).ShouldBeFalse(
$"key[{i - 2}] should be evicted after adding key[{i}]");
// key one position back should still be present.
File.Exists(Path.Combine(dir, keys[i - 1] + ".jwt")).ShouldBeTrue();
// current key should be present.
File.Exists(Path.Combine(dir, keys[i] + ".jwt")).ShouldBeTrue();
}
}
// -------------------------------------------------------------------------
// T:290 — TestLru
// -------------------------------------------------------------------------
[Fact] // T:290
public async Task Lru_EvictsAndExpires()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(50),
limit: 2,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
var key1 = NextKey();
var key2 = NextKey();
var key3 = NextKey();
CreateTestAccount(store, key1, 1000);
CountJwtFiles(dir).ShouldBe(1);
CreateTestAccount(store, key2, 1000);
CountJwtFiles(dir).ShouldBe(2);
// Adding key3 should evict key1 (oldest).
CreateTestAccount(store, key3, 1000);
CountJwtFiles(dir).ShouldBe(2);
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
// Update key2 → moves it to MRU. key3 becomes LRU.
CreateTestAccount(store, key2, 1000);
CountJwtFiles(dir).ShouldBe(2);
// Recreate key1 (which was evicted) → evicts key3.
CreateTestAccount(store, key1, 1); // expires in 1 s
CountJwtFiles(dir).ShouldBe(2);
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
// Let key1 expire (1 s + 1 s buffer for rounding).
await Task.Delay(2200);
CountJwtFiles(dir).ShouldBe(1);
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
// Recreate key3 — no eviction needed, slot is free.
CreateTestAccount(store, key3, 1000);
CountJwtFiles(dir).ShouldBe(2);
}
// -------------------------------------------------------------------------
// T:291 — TestReload
// -------------------------------------------------------------------------
[Fact] // T:291
public void Reload_DetectsFilesAddedAndRemoved()
{
var dir = MakeTempDir();
var notificationChan = new System.Collections.Concurrent.ConcurrentQueue<string>();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(100),
limit: 2,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: pk => notificationChan.Enqueue(pk));
CountJwtFiles(dir).ShouldBe(0);
var emptyHash = new byte[32];
store.Hash().ShouldBe(emptyHash);
var files = new List<string>();
// Add 5 accounts by writing to disk directly, then Reload().
for (var i = 0; i < 5; i++)
{
var key = NextKey();
var exp = DateTimeOffset.UtcNow.AddSeconds(10000).ToUnixTimeSeconds();
var jwt = MakeFakeJwt(exp);
var path = Path.Combine(dir, key + ".jwt");
File.WriteAllText(path, jwt);
files.Add(path);
store.Reload();
// Wait briefly for notification.
var deadline = DateTime.UtcNow.AddMilliseconds(500);
while (notificationChan.IsEmpty && DateTime.UtcNow < deadline)
Thread.Sleep(10);
notificationChan.TryDequeue(out _);
CountJwtFiles(dir).ShouldBe(i + 1);
store.Hash().ShouldNotBe(emptyHash);
var packed = store.Pack(-1);
packed.Split('\n').Length.ShouldBe(i + 1);
}
// Now remove files one by one.
foreach (var f in files)
{
var hash = store.Hash();
hash.ShouldNotBe(emptyHash);
File.Delete(f);
store.Reload();
CountJwtFiles(dir).ShouldBe(files.Count - files.IndexOf(f) - 1);
}
store.Hash().ShouldBe(emptyHash);
}
// -------------------------------------------------------------------------
// T:292 — TestExpirationUpdate
// -------------------------------------------------------------------------
[Fact] // T:292
public async Task ExpirationUpdate_UpdatingExpirationExtendsTTL()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(50),
limit: 10,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
var key = NextKey();
var h = store.Hash();
// Save account with no expiry.
CreateTestAccount(store, key, 0);
var nh = store.Hash();
nh.ShouldNotBe(h);
h = nh;
await Task.Delay(1500);
CountJwtFiles(dir).ShouldBe(1); // should NOT have expired (no exp claim)
// Save same account with 2-second expiry.
CreateTestAccount(store, key, 2);
nh = store.Hash();
nh.ShouldNotBe(h);
h = nh;
await Task.Delay(1500);
CountJwtFiles(dir).ShouldBe(1); // not expired yet
// Save with no expiry again — resets expiry on that account.
CreateTestAccount(store, key, 0);
nh = store.Hash();
nh.ShouldNotBe(h);
h = nh;
await Task.Delay(1500);
CountJwtFiles(dir).ShouldBe(1); // still NOT expired
// Now save with 1-second expiry.
CreateTestAccount(store, key, 1);
nh = store.Hash();
nh.ShouldNotBe(h);
await Task.Delay(1500);
CountJwtFiles(dir).ShouldBe(0); // should be expired now
var empty = new byte[32];
store.Hash().ShouldBe(empty);
}
// -------------------------------------------------------------------------
// T:293 — TestTTL
// -------------------------------------------------------------------------
[Fact] // T:293
public async Task TTL_AccessResetsExpirationOnStore()
{
var dir = MakeTempDir();
var key = NextKey();
// TTL = 200 ms. Each access (Load or Save) should reset expiry.
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: TimeSpan.FromMilliseconds(50),
limit: 10,
evictOnLimit: true,
ttl: TimeSpan.FromMilliseconds(200),
changeNotification: null);
CreateTestAccount(store, key, 0);
CountJwtFiles(dir).ShouldBe(1);
// Access every 110 ms — should prevent expiration.
for (var i = 0; i < 4; i++)
{
await Task.Delay(110);
store.LoadAcc(key); // TTL reset via Load
CountJwtFiles(dir).ShouldBe(1);
}
// Stop accessing — wait for expiration.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(50);
if (CountJwtFiles(dir) == 0)
return; // expired as expected
}
Assert.Fail("JWT should have expired by now via TTL");
}
// -------------------------------------------------------------------------
// T:294 — TestRemove
// -------------------------------------------------------------------------
[Fact] // T:294
public void Remove_RespectsDeleteType()
{
foreach (var (deleteType, expectedJwt, expectedDeleted) in new[]
{
(JwtDeleteType.HardDelete, 0, 0),
(JwtDeleteType.RenameDeleted, 0, 1),
(JwtDeleteType.NoDelete, 1, 0),
})
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewExpiringDirJwtStore(
dir, shard: false, create: false,
deleteType: deleteType,
expireCheck: TimeSpan.Zero,
limit: 10,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
var key = NextKey();
CreateTestAccount(store, key, 0);
CountJwtFiles(dir).ShouldBe(1, $"deleteType={deleteType}: should have 1 jwt before delete");
// For HardDelete and RenameDeleted the store must allow Delete.
// For NoDelete, Delete should throw.
if (deleteType == JwtDeleteType.NoDelete)
{
Should.Throw<InvalidOperationException>(() => store.Delete(key),
$"deleteType={deleteType}: should throw on delete");
}
else
{
store.Delete(key);
}
// Count .jwt files (not .jwt.deleted).
var jwtFiles = Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
jwtFiles.ShouldBe(expectedJwt, $"deleteType={deleteType}: unexpected jwt count");
// Count .jwt.deleted files.
var deletedFiles = Directory.GetFiles(dir, "*.jwt.deleted", SearchOption.AllDirectories).Length;
deletedFiles.ShouldBe(expectedDeleted, $"deleteType={deleteType}: unexpected deleted count");
}
}
// -------------------------------------------------------------------------
// T:295 — TestNotificationOnPack
// -------------------------------------------------------------------------
[Fact] // T:295
public void NotificationOnPack_MergeFiresChangedCallback()
{
// Pre-populate a store with 4 accounts, pack it, then Merge into new stores.
// Each Merge should fire the change notification for every key.
const int JwtCount = 4;
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
var dirPack = MakeTempDir();
var keys = new string[JwtCount];
var jwts = new string[JwtCount];
var notifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
using var packStore = DirJwtStore.NewExpiringDirJwtStore(
dirPack, shard: false, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: infDur,
limit: 0,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: pk => notifications.Enqueue(pk));
for (var i = 0; i < JwtCount; i++)
{
keys[i] = NextKey();
jwts[i] = MakeFakeJwt(0); // no expiry
packStore.SaveAcc(keys[i], jwts[i]);
}
// Drain initial notifications.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (notifications.Count < JwtCount && DateTime.UtcNow < deadline)
Thread.Sleep(10);
while (notifications.TryDequeue(out _)) { }
var msg = packStore.Pack(-1);
var hash = packStore.Hash();
// Merge into new stores (sharded and unsharded).
foreach (var shard in new[] { true, false, true, false })
{
var dirMerge = MakeTempDir();
var mergeNotifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
using var mergeStore = DirJwtStore.NewExpiringDirJwtStore(
dirMerge, shard: shard, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: infDur,
limit: 0,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: pk => mergeNotifications.Enqueue(pk));
mergeStore.Merge(msg);
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
// Hashes must match.
packStore.Hash().ShouldBe(hash);
// Wait for JwtCount notifications.
deadline = DateTime.UtcNow.AddSeconds(2);
while (mergeNotifications.Count < JwtCount && DateTime.UtcNow < deadline)
Thread.Sleep(10);
mergeNotifications.Count.ShouldBeGreaterThanOrEqualTo(JwtCount);
// Double-merge should produce no extra file changes.
while (mergeNotifications.TryDequeue(out _)) { }
mergeStore.Merge(msg);
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
Thread.Sleep(50);
mergeNotifications.IsEmpty.ShouldBeTrue("no new notifications on re-merge of identical JWTs");
msg = mergeStore.Pack(-1);
}
// All original JWTs can still be loaded from the last pack.
for (var i = 0; i < JwtCount; i++)
{
var found = msg.Contains(keys[i] + "|" + jwts[i]);
found.ShouldBeTrue($"key {keys[i]} should be in packed message");
}
}
// -------------------------------------------------------------------------
// T:296 — TestNotificationOnPackWalk
// -------------------------------------------------------------------------
[Fact] // T:296
public void NotificationOnPackWalk_PropagatesAcrossChainOfStores()
{
const int StoreCnt = 5;
const int KeyCnt = 50;
const int IterCnt = 4; // reduced from Go's 8 to keep test fast
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
var stores = new DirJwtStore[StoreCnt];
var dirs = new string[StoreCnt];
try
{
for (var i = 0; i < StoreCnt; i++)
{
dirs[i] = MakeTempDir();
stores[i] = DirJwtStore.NewExpiringDirJwtStore(
dirs[i], shard: true, create: false,
deleteType: JwtDeleteType.NoDelete,
expireCheck: infDur,
limit: 0,
evictOnLimit: true,
ttl: TimeSpan.Zero,
changeNotification: null);
}
for (var iter = 0; iter < IterCnt; iter++)
{
// Fill store[0] with KeyCnt new accounts.
for (var j = 0; j < KeyCnt; j++)
{
var k = NextKey();
var jwt = MakeFakeJwt(0);
stores[0].SaveAcc(k, jwt);
}
// Propagate via PackWalk from store[n] → store[n+1].
for (var j = 0; j < StoreCnt - 1; j++)
{
stores[j].PackWalk(3, partial => stores[j + 1].Merge(partial));
}
// Verify all adjacent store hashes match.
for (var j = 0; j < StoreCnt - 1; j++)
{
stores[j].Hash().ShouldBe(stores[j + 1].Hash(),
$"stores[{j}] and stores[{j + 1}] should have matching hashes after iteration {iter}");
}
}
}
finally
{
foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ }
}
}
}

View File

@@ -0,0 +1,113 @@
// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Mirrors server/jetstream_batching_test.go in the NATS server Go source.
// ALL tests in this file are deferred: they all use createJetStreamClusterExplicit()
// or RunBasicJetStreamServer() and require a running JetStream cluster/server.
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
/// <summary>
/// Tests for JetStream atomic batch publishing.
/// Mirrors server/jetstream_batching_test.go.
/// All tests are deferred pending JetStream server infrastructure.
/// </summary>
public sealed class JetStreamBatchingTests
{
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:716
public void JetStreamAtomicBatchPublish_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:717
public void JetStreamAtomicBatchPublishEmptyAck_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:718
public void JetStreamAtomicBatchPublishCommitEob_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:719
public void JetStreamAtomicBatchPublishLimits_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:720
public void JetStreamAtomicBatchPublishDedupeNotAllowed_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:721
public void JetStreamAtomicBatchPublishSourceAndMirror_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:722
public void JetStreamAtomicBatchPublishCleanup_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:723
public void JetStreamAtomicBatchPublishConfigOpts_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:724
public void JetStreamAtomicBatchPublishDenyHeaders_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:725
public void JetStreamAtomicBatchPublishStageAndCommit_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:726
public void JetStreamAtomicBatchPublishHighLevelRollback_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:727
public void JetStreamAtomicBatchPublishExpectedPerSubject_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:728
public void JetStreamAtomicBatchPublishSingleServerRecovery_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:729
public void JetStreamAtomicBatchPublishSingleServerRecoveryCommitEob_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:730
public void JetStreamAtomicBatchPublishEncode_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:731
public void JetStreamAtomicBatchPublishProposeOne_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:732
public void JetStreamAtomicBatchPublishProposeMultiple_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:733
public void JetStreamAtomicBatchPublishProposeOnePartialBatch_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:734
public void JetStreamAtomicBatchPublishProposeMultiplePartialBatches_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:735
public void JetStreamAtomicBatchPublishContinuousBatchesStillMoveAppliedUp_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:736
public void JetStreamAtomicBatchPublishPartiallyAppliedBatchOnRecovery_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:737
public void JetStreamRollupIsolatedRead_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:738
public void JetStreamAtomicBatchPublishAdvisories_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:739
public void JetStreamAtomicBatchPublishExpectedSeq_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:740
public void JetStreamAtomicBatchPublishPartialBatchInSharedAppendEntry_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:741
public void JetStreamAtomicBatchPublishRejectPartialBatchOnLeaderChange_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:742
public void JetStreamAtomicBatchPublishPersistModeAsync_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:743
public void JetStreamAtomicBatchPublishExpectedLastSubjectSequence_RequiresRunningServer() { }
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:744
public void JetStreamAtomicBatchPublishCommitUnsupported_RequiresRunningServer() { }
}

View File

@@ -0,0 +1,50 @@
// Copyright 2020-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Mirrors server/jetstream_errors_test.go in the NATS server Go source.
//
// All 4 tests are deferred:
// T:1381 — TestIsNatsErr: uses IsNatsErr(error, ...) where the Go version accepts
// arbitrary error interface values (including plain errors.New("x") which
// evaluates to false). The .NET JsApiErrors.IsNatsError only accepts JsApiError?
// and the "NewJS*" factory constructors (NewJSRestoreSubscribeFailedError etc.)
// that populate Description templates from tags have not been ported yet.
// T:1382 — TestApiError_Error: uses ApiErrors[JSClusterNotActiveErr].Error() — the Go
// ApiErrors map and per-error .Error() method (returns "description (errCode)")
// differs from the .NET JsApiErrors.ClusterNotActive.ToString() convention.
// T:1383 — TestApiError_NewWithTags: uses NewJSRestoreSubscribeFailedError with tag
// substitution — factory constructors not yet ported.
// T:1384 — TestApiError_NewWithUnless: uses NewJSStreamRestoreError, Unless() helper,
// NewJSPeerRemapError — not yet ported.
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
/// <summary>
/// Tests for JetStream API error types and IsNatsErr helper.
/// Mirrors server/jetstream_errors_test.go.
/// All tests deferred pending port of Go factory constructors and tag-substitution system.
/// </summary>
public sealed class JetStreamErrorsTests
{
[Fact(Skip = "deferred: NewJS* factory constructors and IsNatsErr(error) not yet ported")] // T:1381
public void IsNatsErr_ShouldSucceed() { }
[Fact(Skip = "deferred: ApiErrors map and .Error() method not yet ported")] // T:1382
public void ApiError_Error_ShouldSucceed() { }
[Fact(Skip = "deferred: NewJSRestoreSubscribeFailedError with tag substitution not yet ported")] // T:1383
public void ApiError_NewWithTags_ShouldSucceed() { }
[Fact(Skip = "deferred: NewJSStreamRestoreError / Unless() helper not yet ported")] // T:1384
public void ApiError_NewWithUnless_ShouldSucceed() { }
}

View File

@@ -0,0 +1,416 @@
// Copyright 2024-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Mirrors server/jetstream_versioning_test.go in the NATS server Go source.
using Shouldly;
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
/// <summary>
/// Unit tests for JetStream API level versioning helpers.
/// Mirrors server/jetstream_versioning_test.go.
/// Tests 18031808 (TestJetStreamMetadataMutations, TestJetStreamMetadataStreamRestoreAndRestart,
/// TestJetStreamMetadataStreamRestoreAndRestartCluster, TestJetStreamApiErrorOnRequiredApiLevel,
/// TestJetStreamApiErrorOnRequiredApiLevelDirectGet, TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg)
/// all require a running JetStream server and are deferred.
/// </summary>
public sealed class JetStreamVersioningTests
{
// -------------------------------------------------------------------------
// Helpers (mirrors module-level helpers in Go test file)
// -------------------------------------------------------------------------
private static Dictionary<string, string> MetadataAtLevel(string featureLevel) =>
new() { [JetStreamVersioning.JsRequiredLevelMetadataKey] = featureLevel };
private static Dictionary<string, string> MetadataPrevious() =>
new() { [JetStreamVersioning.JsRequiredLevelMetadataKey] = "previous-level" };
// -------------------------------------------------------------------------
// T:1791 — TestGetAndSupportsRequiredApiLevel
// -------------------------------------------------------------------------
[Fact] // T:1791
public void GetAndSupportsRequiredApiLevel_VariousInputs_ReturnsExpected()
{
// getRequiredApiLevel
JetStreamVersioning.GetRequiredApiLevel(null).ShouldBe(string.Empty);
JetStreamVersioning.GetRequiredApiLevel(new Dictionary<string, string>()).ShouldBe(string.Empty);
JetStreamVersioning.GetRequiredApiLevel(MetadataAtLevel("1")).ShouldBe("1");
JetStreamVersioning.GetRequiredApiLevel(MetadataAtLevel("text")).ShouldBe("text");
// supportsRequiredApiLevel
JetStreamVersioning.SupportsRequiredApiLevel(null).ShouldBeTrue();
JetStreamVersioning.SupportsRequiredApiLevel(new Dictionary<string, string>()).ShouldBeTrue();
JetStreamVersioning.SupportsRequiredApiLevel(MetadataAtLevel("1")).ShouldBeTrue();
JetStreamVersioning.SupportsRequiredApiLevel(
MetadataAtLevel(JetStreamVersioning.JsApiLevel.ToString())).ShouldBeTrue();
JetStreamVersioning.SupportsRequiredApiLevel(MetadataAtLevel("text")).ShouldBeFalse();
}
// -------------------------------------------------------------------------
// T:1792 — TestJetStreamSetStaticStreamMetadata
// -------------------------------------------------------------------------
[Fact] // T:1792
public void SetStaticStreamMetadata_VariousConfigs_SetsCorrectApiLevel()
{
var cases = new[]
{
("empty", new StreamConfig(), "0"),
("overwrite-user-provided", new StreamConfig { Metadata = MetadataPrevious() }, "0"),
("AllowMsgTTL", new StreamConfig { AllowMsgTTL = true }, "1"),
("SubjectDeleteMarkerTTL", new StreamConfig { SubjectDeleteMarkerTTL = TimeSpan.FromSeconds(1) }, "1"),
("AllowMsgCounter", new StreamConfig { AllowMsgCounter = true }, "2"),
("AllowAtomicPublish", new StreamConfig { AllowAtomicPublish = true }, "2"),
("AllowMsgSchedules", new StreamConfig { AllowMsgSchedules = true }, "2"),
("AsyncPersistMode", new StreamConfig { PersistMode = PersistModeType.AsyncPersistMode }, "2"),
};
foreach (var (desc, cfg, expectedLevel) in cases)
{
JetStreamVersioning.SetStaticStreamMetadata(cfg);
var level = cfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey];
level.ShouldBe(expectedLevel, $"case: {desc}");
// Ensure we do not exceed the server API level.
int.Parse(level).ShouldBeLessThanOrEqualTo(JetStreamVersioning.JsApiLevel,
customMessage: $"case: {desc}");
}
}
// -------------------------------------------------------------------------
// T:1793 — TestJetStreamSetStaticStreamMetadataRemoveDynamicFields
// -------------------------------------------------------------------------
[Fact] // T:1793
public void SetStaticStreamMetadata_RemovesDynamicFields()
{
var cfg = new StreamConfig
{
Metadata = new Dictionary<string, string>
{
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
}
};
JetStreamVersioning.SetStaticStreamMetadata(cfg);
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
cfg.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
}
// -------------------------------------------------------------------------
// T:1794 — TestJetStreamSetDynamicStreamMetadata
// -------------------------------------------------------------------------
[Fact] // T:1794
public void SetDynamicStreamMetadata_DoesNotMutateOriginal_AddsVersionFields()
{
var cfg = new StreamConfig { Metadata = MetadataAtLevel("0") };
var newCfg = JetStreamVersioning.SetDynamicStreamMetadata(cfg);
// Original must NOT have dynamic fields.
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
// New copy must have dynamic fields.
newCfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
newCfg.Metadata[JetStreamVersioning.JsServerVersionMetadataKey].ShouldBe(ServerConstants.Version);
newCfg.Metadata[JetStreamVersioning.JsServerLevelMetadataKey]
.ShouldBe(JetStreamVersioning.JsApiLevel.ToString());
}
// -------------------------------------------------------------------------
// T:1795 — TestJetStreamCopyStreamMetadata
// -------------------------------------------------------------------------
[Fact] // T:1795
public void CopyStreamMetadata_VariousScenarios_CopiesRequiredLevelKey()
{
// no-previous-ignore: when prevCfg is null, key must be absent
var cfg1 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyStreamMetadata(cfg1, null);
(cfg1.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
// nil-previous-metadata-ignore: prevCfg has null Metadata
var cfg2 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyStreamMetadata(cfg2, new StreamConfig { Metadata = null });
(cfg2.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
// nil-current-metadata-ignore: cfg has null Metadata — should not throw
var cfg3 = new StreamConfig { Metadata = null };
JetStreamVersioning.CopyStreamMetadata(cfg3, new StreamConfig { Metadata = MetadataPrevious() });
cfg3.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
// copy-previous: key from prevCfg is copied into cfg
var cfg4 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyStreamMetadata(cfg4, new StreamConfig { Metadata = MetadataPrevious() });
cfg4.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
// delete-missing-fields: prevCfg has empty metadata dict → key absent in cfg
var cfg5 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyStreamMetadata(cfg5, new StreamConfig { Metadata = new Dictionary<string, string>() });
(cfg5.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
}
// -------------------------------------------------------------------------
// T:1796 — TestJetStreamCopyStreamMetadataRemoveDynamicFields
// -------------------------------------------------------------------------
[Fact] // T:1796
public void CopyStreamMetadata_RemovesDynamicFields()
{
// Copy from null prevCfg — dynamic fields should be removed and key absent.
var cfg = new StreamConfig
{
Metadata = new Dictionary<string, string>
{
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
}
};
JetStreamVersioning.CopyStreamMetadata(cfg, null);
cfg.Metadata.ShouldBeNull(); // all entries removed → null'd
// Copy from prevCfg with req-level → dynamic fields removed, req-level preserved.
var cfg2 = new StreamConfig
{
Metadata = new Dictionary<string, string>
{
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
}
};
var prev = new StreamConfig { Metadata = MetadataAtLevel("0") };
JetStreamVersioning.CopyStreamMetadata(cfg2, prev);
cfg2.Metadata.ShouldNotBeNull();
cfg2.Metadata!.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
cfg2.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
cfg2.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
}
// -------------------------------------------------------------------------
// T:1797 — TestJetStreamSetStaticConsumerMetadata
// -------------------------------------------------------------------------
[Fact] // T:1797
public void SetStaticConsumerMetadata_VariousConfigs_SetsCorrectApiLevel()
{
var pauseUntil = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc); // Unix(0, 0) = epoch+1s
var pauseUntilZero = default(DateTime);
var cases = new[]
{
("empty", new ConsumerConfig(), "0"),
("overwrite-user-provided", new ConsumerConfig { Metadata = MetadataPrevious() }, "0"),
("PauseUntil/zero", new ConsumerConfig { PauseUntil = pauseUntilZero }, "0"),
("PauseUntil", new ConsumerConfig { PauseUntil = pauseUntil }, "1"),
("Pinned", new ConsumerConfig { PriorityPolicy = PriorityPolicy.PriorityPinnedClient,
PriorityGroups = new[] { "a" } }, "1"),
};
foreach (var (desc, cfg, expectedLevel) in cases)
{
JetStreamVersioning.SetStaticConsumerMetadata(cfg);
var level = cfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey];
level.ShouldBe(expectedLevel, $"case: {desc}");
int.Parse(level).ShouldBeLessThanOrEqualTo(JetStreamVersioning.JsApiLevel,
customMessage: $"case: {desc}");
}
}
// -------------------------------------------------------------------------
// T:1798 — TestJetStreamSetStaticConsumerMetadataRemoveDynamicFields
// -------------------------------------------------------------------------
[Fact] // T:1798
public void SetStaticConsumerMetadata_RemovesDynamicFields()
{
var cfg = new ConsumerConfig
{
Metadata = new Dictionary<string, string>
{
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
}
};
JetStreamVersioning.SetStaticConsumerMetadata(cfg);
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
cfg.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
}
// -------------------------------------------------------------------------
// T:1799 — TestJetStreamSetDynamicConsumerMetadata
// -------------------------------------------------------------------------
[Fact] // T:1799
public void SetDynamicConsumerMetadata_DoesNotMutateOriginal_AddsVersionFields()
{
var cfg = new ConsumerConfig { Metadata = MetadataAtLevel("0") };
var newCfg = JetStreamVersioning.SetDynamicConsumerMetadata(cfg);
// Original must NOT have dynamic fields.
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
// New copy must have dynamic fields.
newCfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
newCfg.Metadata[JetStreamVersioning.JsServerVersionMetadataKey].ShouldBe(ServerConstants.Version);
newCfg.Metadata[JetStreamVersioning.JsServerLevelMetadataKey]
.ShouldBe(JetStreamVersioning.JsApiLevel.ToString());
}
// -------------------------------------------------------------------------
// T:1800 — TestJetStreamSetDynamicConsumerInfoMetadata
// -------------------------------------------------------------------------
[Fact] // T:1800
public void SetDynamicConsumerInfoMetadata_DoesNotMutateOriginal_AddsVersionFields()
{
var ci = new ConsumerInfo { Config = new ConsumerConfig { Metadata = MetadataAtLevel("0") } };
var newCi = JetStreamVersioning.SetDynamicConsumerInfoMetadata(ci);
// Configs must not be reference-equal (we got a new object).
ReferenceEquals(ci, newCi).ShouldBeFalse();
// Original config must NOT have dynamic fields.
ci.Config!.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
ci.Config.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
// New config must have dynamic fields.
newCi.Config!.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
newCi.Config.Metadata[JetStreamVersioning.JsServerVersionMetadataKey].ShouldBe(ServerConstants.Version);
newCi.Config.Metadata[JetStreamVersioning.JsServerLevelMetadataKey]
.ShouldBe(JetStreamVersioning.JsApiLevel.ToString());
}
// -------------------------------------------------------------------------
// T:1801 — TestJetStreamCopyConsumerMetadata
// -------------------------------------------------------------------------
[Fact] // T:1801
public void CopyConsumerMetadata_VariousScenarios_CopiesRequiredLevelKey()
{
// no-previous-ignore
var cfg1 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyConsumerMetadata(cfg1, null);
(cfg1.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
// nil-previous-metadata-ignore
var cfg2 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyConsumerMetadata(cfg2, new ConsumerConfig { Metadata = null });
(cfg2.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
// nil-current-metadata-ignore
var cfg3 = new ConsumerConfig { Metadata = null };
JetStreamVersioning.CopyConsumerMetadata(cfg3, new ConsumerConfig { Metadata = MetadataPrevious() });
cfg3.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
// copy-previous
var cfg4 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyConsumerMetadata(cfg4, new ConsumerConfig { Metadata = MetadataPrevious() });
cfg4.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
// delete-missing-fields
var cfg5 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
JetStreamVersioning.CopyConsumerMetadata(cfg5,
new ConsumerConfig { Metadata = new Dictionary<string, string>() });
(cfg5.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
}
// -------------------------------------------------------------------------
// T:1802 — TestJetStreamCopyConsumerMetadataRemoveDynamicFields
// -------------------------------------------------------------------------
[Fact] // T:1802
public void CopyConsumerMetadata_RemovesDynamicFields()
{
// Copy from null prevCfg → dynamic removed, key absent.
var cfg = new ConsumerConfig
{
Metadata = new Dictionary<string, string>
{
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
}
};
JetStreamVersioning.CopyConsumerMetadata(cfg, null);
cfg.Metadata.ShouldBeNull();
// Copy from prevCfg with req-level → dynamic removed, req-level preserved.
var cfg2 = new ConsumerConfig
{
Metadata = new Dictionary<string, string>
{
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
}
};
var prev = new ConsumerConfig { Metadata = MetadataAtLevel("0") };
JetStreamVersioning.CopyConsumerMetadata(cfg2, prev);
cfg2.Metadata.ShouldNotBeNull();
cfg2.Metadata!.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
cfg2.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
cfg2.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
}
// -------------------------------------------------------------------------
// T:1803 — TestJetStreamMetadataMutations — deferred: requires RunBasicJetStreamServer
// -------------------------------------------------------------------------
[Fact(Skip = "deferred: requires running JetStream server")] // T:1803
public void JetStreamMetadataMutations_RequiresRunningServer() { }
// -------------------------------------------------------------------------
// T:1804 — TestJetStreamMetadataStreamRestoreAndRestart — deferred
// -------------------------------------------------------------------------
[Fact(Skip = "deferred: requires running JetStream server")] // T:1804
public void JetStreamMetadataStreamRestoreAndRestart_RequiresRunningServer() { }
// -------------------------------------------------------------------------
// T:1805 — TestJetStreamMetadataStreamRestoreAndRestartCluster — deferred
// -------------------------------------------------------------------------
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:1805
public void JetStreamMetadataStreamRestoreAndRestartCluster_RequiresRunningServer() { }
// -------------------------------------------------------------------------
// T:1806 — TestJetStreamApiErrorOnRequiredApiLevel — deferred
// -------------------------------------------------------------------------
[Fact(Skip = "deferred: requires running JetStream server")] // T:1806
public void JetStreamApiErrorOnRequiredApiLevel_RequiresRunningServer() { }
// -------------------------------------------------------------------------
// T:1807 — TestJetStreamApiErrorOnRequiredApiLevelDirectGet — deferred
// -------------------------------------------------------------------------
[Fact(Skip = "deferred: requires running JetStream server")] // T:1807
public void JetStreamApiErrorOnRequiredApiLevelDirectGet_RequiresRunningServer() { }
// -------------------------------------------------------------------------
// T:1808 — TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg — deferred
// -------------------------------------------------------------------------
[Fact(Skip = "deferred: requires running JetStream server")] // T:1808
public void JetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg_RequiresRunningServer() { }
}