diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
index 980d099..ad045e4 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
@@ -624,7 +624,7 @@ public sealed class DirJwtStore : IDisposable
/// Deletes the JWT for according to .
/// Mirrors Go DirJWTStore.delete.
///
- private void Delete(string publicKey)
+ public void Delete(string publicKey)
{
if (_readonly)
{
@@ -795,7 +795,7 @@ public sealed class DirJwtStore : IDisposable
// Background timer — mirrors Go goroutine + time.Ticker.
var timer = new Timer(_ =>
{
- var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * TimeSpan.TicksPerMillisecond;
+ var now = DateTimeOffset.UtcNow.UtcTicks;
while (true)
{
@@ -1104,14 +1104,13 @@ internal sealed class ExpirationTracker
// Remove old hash contribution from rolling XOR.
XorAssign(_hash, existing.Hash);
- // Update in-place.
- existing.Expiration = exp;
- existing.Hash = hash;
-
- // Re-enqueue with updated priority (PriorityQueue does not support update;
- // use a version counter approach — mark old entry stale, enqueue fresh).
- existing.Version++;
- _heap.Enqueue(existing, exp);
+ // Create a new JwtItem so the old heap entry becomes a stale orphan.
+ // DrainStale uses ReferenceEquals(current, top) to detect orphans:
+ // the old heap entry points to the old JwtItem object which is no longer
+ // in _idx, so it will be drained on the next PeekExpired call.
+ var updated = new JwtItem(publicKey, exp, hash);
+ _idx[publicKey] = updated;
+ _heap.Enqueue(updated, exp);
}
else
{
@@ -1141,9 +1140,11 @@ internal sealed class ExpirationTracker
? long.MaxValue
: (DateTimeOffset.UtcNow + Ttl).UtcTicks;
- item.Expiration = newExp;
- item.Version++;
- _heap.Enqueue(item, newExp);
+ // Replace with a new JwtItem so the old heap entry becomes a stale orphan
+ // (DrainStale detects staleness via ReferenceEquals).
+ var updated = new JwtItem(publicKey, newExp, item.Hash);
+ _idx[publicKey] = updated;
+ _heap.Enqueue(updated, newExp);
}
if (EvictOnLimit)
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamVersioning.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamVersioning.cs
index 6a6af5e..b7c9066 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamVersioning.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamVersioning.cs
@@ -103,4 +103,224 @@ public static class JetStreamVersioning
if (string.IsNullOrEmpty(reqApiLevelHeader)) return false;
return !int.TryParse(reqApiLevelHeader, out var minLevel) || JsApiLevel < minLevel;
}
+
+ // ---- Stream metadata mutations ----
+
+ ///
+ /// Sets the required API level in stream config metadata based on which v2.11+/v2.12+ features
+ /// the stream config uses. Removes any dynamic fields first.
+ /// Mirrors setStaticStreamMetadata.
+ ///
+ public static void SetStaticStreamMetadata(StreamConfig cfg)
+ {
+ cfg.Metadata ??= new Dictionary();
+ DeleteDynamicMetadata(cfg.Metadata);
+
+ var requiredApiLevel = 0;
+ void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; }
+
+ if (cfg.AllowMsgTTL || cfg.SubjectDeleteMarkerTTL > TimeSpan.Zero)
+ Requires(ApiLevelForTTL);
+ if (cfg.AllowMsgCounter)
+ Requires(ApiLevelForCounters);
+ if (cfg.AllowAtomicPublish)
+ Requires(ApiLevelForAtomicPublish);
+ if (cfg.AllowMsgSchedules)
+ Requires(ApiLevelForMsgSchedules);
+ if (cfg.PersistMode == PersistModeType.AsyncPersistMode)
+ Requires(ApiLevelForAsyncPersist);
+
+ cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString();
+ }
+
+ ///
+ /// Returns a shallow copy of the stream config with dynamic versioning fields added to a new
+ /// metadata dictionary. Does not mutate .
+ /// Mirrors setDynamicStreamMetadata.
+ ///
+ public static StreamConfig SetDynamicStreamMetadata(StreamConfig cfg)
+ {
+ // Shallow-copy the struct-like record: clone all fields then replace metadata.
+ var newCfg = cfg.Clone();
+ newCfg.Metadata = new Dictionary();
+ if (cfg.Metadata != null)
+ foreach (var kv in cfg.Metadata)
+ newCfg.Metadata[kv.Key] = kv.Value;
+ newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version;
+ newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString();
+ return newCfg;
+ }
+
+ ///
+ /// Copies the required-level versioning field from into
+ /// , removing dynamic fields and deleting the key if absent in prevCfg.
+ /// Mirrors copyStreamMetadata.
+ ///
+ public static void CopyStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg)
+ {
+ if (cfg.Metadata != null)
+ DeleteDynamicMetadata(cfg.Metadata);
+ SetOrDeleteInStreamMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey);
+ }
+
+ private static void SetOrDeleteInStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg, string key)
+ {
+ if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value))
+ {
+ cfg.Metadata ??= new Dictionary();
+ cfg.Metadata[key] = value;
+ return;
+ }
+ if (cfg.Metadata != null)
+ {
+ cfg.Metadata.Remove(key);
+ if (cfg.Metadata.Count == 0)
+ cfg.Metadata = null;
+ }
+ }
+
+ // ---- Consumer metadata mutations ----
+
+ ///
+ /// Sets the required API level in consumer config metadata based on which v2.11+ features
+ /// the consumer config uses. Removes any dynamic fields first.
+ /// Mirrors setStaticConsumerMetadata.
+ ///
+ public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
+ {
+ cfg.Metadata ??= new Dictionary();
+ DeleteDynamicMetadata(cfg.Metadata);
+
+ var requiredApiLevel = 0;
+ void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; }
+
+ if (cfg.PauseUntil.HasValue && cfg.PauseUntil.Value != default)
+ Requires(ApiLevelForConsumerPause);
+ if (cfg.PriorityPolicy != PriorityPolicy.PriorityNone
+ || cfg.PinnedTTL != TimeSpan.Zero
+ || (cfg.PriorityGroups != null && cfg.PriorityGroups.Length > 0))
+ Requires(ApiLevelForPriorityGroups);
+
+ cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString();
+ }
+
+ ///
+ /// Returns a shallow copy of the consumer config with dynamic versioning fields added to a new
+ /// metadata dictionary. Does not mutate .
+ /// Mirrors setDynamicConsumerMetadata.
+ ///
+ public static ConsumerConfig SetDynamicConsumerMetadata(ConsumerConfig cfg)
+ {
+ var newCfg = new ConsumerConfig();
+ // Copy all fields via serialisation-free approach: copy properties from cfg
+ CopyConsumerConfigFields(cfg, newCfg);
+ newCfg.Metadata = new Dictionary();
+ if (cfg.Metadata != null)
+ foreach (var kv in cfg.Metadata)
+ newCfg.Metadata[kv.Key] = kv.Value;
+ newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version;
+ newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString();
+ return newCfg;
+ }
+
+ ///
+ /// Returns a shallow copy of the consumer info with dynamic versioning fields added to the
+ /// config's metadata. Does not mutate .
+ /// Mirrors setDynamicConsumerInfoMetadata.
+ ///
+ public static ConsumerInfo SetDynamicConsumerInfoMetadata(ConsumerInfo info)
+ {
+ var newInfo = new ConsumerInfo
+ {
+ Stream = info.Stream,
+ Name = info.Name,
+ Created = info.Created,
+ Delivered = info.Delivered,
+ AckFloor = info.AckFloor,
+ NumAckPending = info.NumAckPending,
+ NumRedelivered = info.NumRedelivered,
+ NumWaiting = info.NumWaiting,
+ NumPending = info.NumPending,
+ Cluster = info.Cluster,
+ PushBound = info.PushBound,
+ Paused = info.Paused,
+ PauseRemaining = info.PauseRemaining,
+ TimeStamp = info.TimeStamp,
+ PriorityGroups = info.PriorityGroups,
+ Config = info.Config != null ? SetDynamicConsumerMetadata(info.Config) : null,
+ };
+ return newInfo;
+ }
+
+ ///
+ /// Copies the required-level versioning field from into
+ /// , removing dynamic fields and deleting the key if absent in prevCfg.
+ /// Mirrors copyConsumerMetadata.
+ ///
+ public static void CopyConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg)
+ {
+ if (cfg.Metadata != null)
+ DeleteDynamicMetadata(cfg.Metadata);
+ SetOrDeleteInConsumerMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey);
+ }
+
+ private static void SetOrDeleteInConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg, string key)
+ {
+ if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value))
+ {
+ cfg.Metadata ??= new Dictionary();
+ cfg.Metadata[key] = value;
+ return;
+ }
+ if (cfg.Metadata != null)
+ {
+ cfg.Metadata.Remove(key);
+ if (cfg.Metadata.Count == 0)
+ cfg.Metadata = null;
+ }
+ }
+
+ // ---- Private helpers ----
+
+ ///
+ /// Copies all scalar/reference properties from to ,
+ /// excluding Metadata (which is set separately by the caller).
+ ///
+ private static void CopyConsumerConfigFields(ConsumerConfig src, ConsumerConfig dst)
+ {
+ dst.DeliverPolicy = src.DeliverPolicy;
+ dst.OptStartSeq = src.OptStartSeq;
+ dst.OptStartTime = src.OptStartTime;
+ dst.DeliverSubject = src.DeliverSubject;
+ dst.DeliverGroup = src.DeliverGroup;
+ dst.Durable = src.Durable;
+ dst.Name = src.Name;
+ dst.Description = src.Description;
+ dst.FilterSubject = src.FilterSubject;
+ dst.FilterSubjects = src.FilterSubjects;
+ dst.AckPolicy = src.AckPolicy;
+ dst.AckWait = src.AckWait;
+ dst.MaxDeliver = src.MaxDeliver;
+ dst.BackOff = src.BackOff;
+ dst.ReplayPolicy = src.ReplayPolicy;
+ dst.RateLimit = src.RateLimit;
+ dst.SampleFrequency = src.SampleFrequency;
+ dst.MaxWaiting = src.MaxWaiting;
+ dst.MaxAckPending = src.MaxAckPending;
+ dst.FlowControl = src.FlowControl;
+ dst.Heartbeat = src.Heartbeat;
+ dst.Direct = src.Direct;
+ dst.HeadersOnly = src.HeadersOnly;
+ dst.MaxRequestBatch = src.MaxRequestBatch;
+ dst.MaxRequestMaxBytes = src.MaxRequestMaxBytes;
+ dst.MaxRequestExpires = src.MaxRequestExpires;
+ dst.InactiveThreshold = src.InactiveThreshold;
+ dst.Replicas = src.Replicas;
+ dst.MemoryStorage = src.MemoryStorage;
+ dst.PauseUntil = src.PauseUntil;
+ dst.PinnedTTL = src.PinnedTTL;
+ dst.PriorityPolicy = src.PriorityPolicy;
+ dst.PriorityGroups = src.PriorityGroups;
+ // Metadata is NOT copied here — caller sets it.
+ }
}
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/DirectoryStoreTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/DirectoryStoreTests.cs
new file mode 100644
index 0000000..253782c
--- /dev/null
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/DirectoryStoreTests.cs
@@ -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 285–296 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;
+
+///
+/// Unit tests for expiration, limits, LRU eviction,
+/// reload, TTL and notification behaviour.
+/// Mirrors server/dirstore_test.go tests 285–296.
+///
+[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 _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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Builds a minimal fake JWT string: header.payload.signature
+ /// where the payload contains "exp", "iat" and "jti" claims.
+ ///
+ 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}";
+ }
+
+ ///
+ /// Rounds a to the nearest whole second,
+ /// mirroring Go's time.Now().Round(time.Second).
+ ///
+ 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('/', '_');
+ }
+
+ ///
+ /// Creates and saves a test account JWT in the store.
+ /// == 0 means no expiration.
+ /// Returns the saved JWT string.
+ ///
+ 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;
+ }
+
+ ///
+ /// Counts non-deleted .jwt files in recursively.
+ ///
+ 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(() => 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();
+
+ 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();
+
+ // 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(() => 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();
+ 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();
+ 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 */ }
+ }
+ }
+}
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamBatchingTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamBatchingTests.cs
new file mode 100644
index 0000000..61d2af6
--- /dev/null
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamBatchingTests.cs
@@ -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;
+
+///
+/// Tests for JetStream atomic batch publishing.
+/// Mirrors server/jetstream_batching_test.go.
+/// All tests are deferred pending JetStream server infrastructure.
+///
+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() { }
+}
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamErrorsTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamErrorsTests.cs
new file mode 100644
index 0000000..37b8a66
--- /dev/null
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamErrorsTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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() { }
+}
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamVersioningTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamVersioningTests.cs
new file mode 100644
index 0000000..2dd6769
--- /dev/null
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamVersioningTests.cs
@@ -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;
+
+///
+/// Unit tests for JetStream API level versioning helpers.
+/// Mirrors server/jetstream_versioning_test.go.
+/// Tests 1803–1808 (TestJetStreamMetadataMutations, TestJetStreamMetadataStreamRestoreAndRestart,
+/// TestJetStreamMetadataStreamRestoreAndRestartCluster, TestJetStreamApiErrorOnRequiredApiLevel,
+/// TestJetStreamApiErrorOnRequiredApiLevelDirectGet, TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg)
+/// all require a running JetStream server and are deferred.
+///
+public sealed class JetStreamVersioningTests
+{
+ // -------------------------------------------------------------------------
+ // Helpers (mirrors module-level helpers in Go test file)
+ // -------------------------------------------------------------------------
+
+ private static Dictionary MetadataAtLevel(string featureLevel) =>
+ new() { [JetStreamVersioning.JsRequiredLevelMetadataKey] = featureLevel };
+
+ private static Dictionary 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()).ShouldBe(string.Empty);
+ JetStreamVersioning.GetRequiredApiLevel(MetadataAtLevel("1")).ShouldBe("1");
+ JetStreamVersioning.GetRequiredApiLevel(MetadataAtLevel("text")).ShouldBe("text");
+
+ // supportsRequiredApiLevel
+ JetStreamVersioning.SupportsRequiredApiLevel(null).ShouldBeTrue();
+ JetStreamVersioning.SupportsRequiredApiLevel(new Dictionary()).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
+ {
+ [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() });
+ (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
+ {
+ [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
+ {
+ [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
+ {
+ [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() });
+ (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
+ {
+ [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
+ {
+ [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() { }
+}
diff --git a/porting.db b/porting.db
index 5e95f80..00f5cf5 100644
Binary files a/porting.db and b/porting.db differ
diff --git a/reports/current.md b/reports/current.md
index 122f9c0..f4a9fce 100644
--- a/reports/current.md
+++ b/reports/current.md
@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
-Generated: 2026-02-27 00:40:06 UTC
+Generated: 2026-02-27 01:02:01 UTC
## Modules (12 total)
@@ -21,10 +21,10 @@ Generated: 2026-02-27 00:40:06 UTC
| Status | Count |
|--------|-------|
-| complete | 252 |
-| deferred | 484 |
+| complete | 276 |
+| deferred | 554 |
| n_a | 187 |
-| not_started | 2220 |
+| not_started | 2126 |
| verified | 114 |
## Library Mappings (36 total)
@@ -36,4 +36,4 @@ Generated: 2026-02-27 00:40:06 UTC
## Overall Progress
-**4237/6942 items complete (61.0%)**
+**4261/6942 items complete (61.4%)**
diff --git a/reports/report_6e90eea.md b/reports/report_6e90eea.md
new file mode 100644
index 0000000..f4a9fce
--- /dev/null
+++ b/reports/report_6e90eea.md
@@ -0,0 +1,39 @@
+# NATS .NET Porting Status Report
+
+Generated: 2026-02-27 01:02:01 UTC
+
+## Modules (12 total)
+
+| Status | Count |
+|--------|-------|
+| not_started | 1 |
+| verified | 11 |
+
+## Features (3673 total)
+
+| Status | Count |
+|--------|-------|
+| complete | 3368 |
+| n_a | 26 |
+| verified | 279 |
+
+## Unit Tests (3257 total)
+
+| Status | Count |
+|--------|-------|
+| complete | 276 |
+| deferred | 554 |
+| n_a | 187 |
+| not_started | 2126 |
+| verified | 114 |
+
+## Library Mappings (36 total)
+
+| Status | Count |
+|--------|-------|
+| mapped | 36 |
+
+
+## Overall Progress
+
+**4261/6942 items complete (61.4%)**