From f0faaffe691800fa43aba12973e21416fafd3701 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 26 Feb 2026 20:02:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(p7-09):=20JetStream=20unit=20tests=20?= =?UTF-8?q?=E2=80=94=20versioning=20(12),=20dirstore=20(12),=20batching/er?= =?UTF-8?q?rors=20deferred=20(66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Accounts/DirJwtStore.cs | 27 +- .../JetStream/JetStreamVersioning.cs | 220 +++++ .../Auth/DirectoryStoreTests.cs | 770 ++++++++++++++++++ .../JetStream/JetStreamBatchingTests.cs | 113 +++ .../JetStream/JetStreamErrorsTests.cs | 50 ++ .../JetStream/JetStreamVersioningTests.cs | 416 ++++++++++ porting.db | Bin 3395584 -> 3395584 bytes reports/current.md | 10 +- reports/report_6e90eea.md | 39 + 9 files changed, 1627 insertions(+), 18 deletions(-) create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/DirectoryStoreTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamBatchingTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamErrorsTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamVersioningTests.cs create mode 100644 reports/report_6e90eea.md 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 5e95f804136d4c968b7c3bd1ee9c9c38ac649426..00f5cf50f98df8d31e27cfa27e672af905a16777 100644 GIT binary patch delta 9097 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 delta 3125 zcmZ9OdsNhA8pnT^x&ChR4lrCyhJoQ7N00<5S&p(oNbRXq#HdIKL2k7UEWzNtaWlxuN>d z(xsufl7JDBN9HVQT)Z%}ETr}+@+skOomTR4XCFy??=m5xL%tyo$`|ERu%q0X^}a+- ziITAce2YgV!|?*q+qK@Vv1AL`ph#kXr4uPbA0*{6o($4!<0@D#k-Q?i?j;t{=`0h@ zOJ#!fGd%4hg%Focwn8A6+yP&w6EFDkNLZHNky%*=c}`~Uj3;X)V`wFNJv0Z& zr_ub2#UhqFcuO-su!a4UMI2VkCM<&KTiM@pNuPBKevHWqNpFCO#biGkolV>l`|G_4 zkBh7(L^h9z#=E8_PG(w|R7&gz=3x?hs@JniWR<%;+eFs-6p4f^IIX>49VCC5uYIV! zrESul)mpVity-I+6=^wIs-~;g)o;}c>Jjxr^(}Ri`mEZjHmcR?6tzgrQBzf2xvqSx zTu=@xA1GUu^~w`Ui&C#tDwCCbB|~v5g7b>=3+EAMuXCGogY!w}ayULn=7|~d+D;!TroI2Flu-pFg!qq2Qr5T{KEqou|RRGZh9;d3;2cy z(uN1TJOBmP$ZPMUzcDDXBR`SbWV==+JSSZeptvOQw$6#}-@`Y9?pe@YLfoB~+>LCl z=$2&o_e}Tm><=^CmE!6Sn;R8}ibo}&Jg7ue5-J&$f*OIk1vL_tit?h;P(D;TDg)(5 zWwH(%UA10xgT04(@ejT~mptLwXm<%u8QA*uQqNv z39ooK0SmNr_$TNsm>(YRqy4Zplof#XJ#-!vhmusItvU{w2hhUbd$(| z?&QQA7-&jx7|JY;)gV^GGl?J3W~g{8`KG~|%Tx#7N7N0@gER{UmdB~a$eH|!27ID{ zKVj^nE8&lq+yU6JkIwGuP0+|gLsOPwLiaxEgYu7Q`7dB5|5t~Cs>IvfZB;{^mZDMQ zM?&p>8ibxhG#+a9Q$tdF;qCgYQM`(ef2M=uK+F<(kY|{KmgaCv6ZX>W0Q@fGpNRflo@)d-f-C2;O29Ro{_&?8Xi8h!KNjYsKJXg?9J z>C-vC0`somdHavjHPLl3`-4~}>^epZ`Jt4qfuPO%qu?5EH0cYpscKM}fV~Z|2bO_2(&tpxhpUdstg%$4N6{eo0GhydM&rM5To~0Ab3UU59yuu>n7MaKIkfHWG?#!Rg zQ70@uNAHPlxVaJAKi6z9cn)71^Nmlb<0XDs^Xl`s<`;Xh!M}pNkaL0NnyuDn4{bF( zc7ZlR-Rjr@Mt*@^Gxxo$mUDUNDUW+^biK{r>B&@YF`0sA0*?yKDDl`u31!;caJ! zX;)|}9J~^}T9mHxBpb60Qn5$GOltx^TiNsf_>`) z3OxONMh^6(1f8qLb_dwl?os`H(jfsHwZT-lYRP<+9d-CZk|i9!Y8rgomf!=jG#Cdi z(Pw+F$(Lebk$J(eBl%Im_=mnv>sA*lzj0i*_u3w|{!V^fyac{-{eCuOtADx3w)gmZ zMYeC7zei;M-0n{lnfRLjtjrc{_CF(A!roOgpzWLNXaROj_t~Wp_@hY?Qv{<*%XR+8 zam9Yb_PSM*UzL{N&%~t4c*t9>p-?p=+_uX5>u7L!b)XfJL++)bM zt(p%_oBjDv-W_xU=?>lpd;8oQ`9-(d))PdHMrAQuPqygeWA4kIX<^}632ic)aeqRa zxVq+GHmf-}X3J@BreJK>KfqIVI_BG3ZCk9pmN(@t=}vKskSPf4x$~Z-GP}Faw?OM! z9B&)hA{e{%m$bXpEawf!p#62*%hqLx3_2pl4{SWMQ(R%;@ 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%)**