feat(p7-09): JetStream unit tests — versioning (12), dirstore (12), batching/errors deferred (66)
Port session P7-09: add tests from jetstream_versioning_test.go (T:1791–1808),
dirstore_test.go (T:285–296), jetstream_batching_test.go (T:716–744),
jetstream_errors_test.go (T:1381–1384), and accounts_test.go (T:80–110).
- JetStreamVersioningTests: 12 active unit tests + 6 deferred (server-required)
- DirectoryStoreTests: 12 filesystem tests using fake JWTs (no NKeys dependency)
- JetStreamBatchingTests: 29 deferred stubs (all require running JetStream cluster)
- JetStreamErrorsTests: 4 deferred stubs (NewJS* factories not yet ported)
- accounts_test.go T:80–110: 31 deferred (all use RunServerWithConfig)
Fix DirJwtStore.cs expiration bugs:
- Use DateTimeOffset.UtcNow.UtcTicks (not Unix-relative ticks) for expiry comparison
- Replace in-place JwtItem mutation with new-object replacement so DrainStale
can detect stale heap entries via ReferenceEquals check
Add JetStreamVersioning.cs methods: SetStaticStreamMetadata,
SetDynamicStreamMetadata, CopyStreamMetadata, SetStaticConsumerMetadata,
SetDynamicConsumerMetadata, SetDynamicConsumerInfoMetadata, CopyConsumerMetadata.
Tests: 725 pass, 53 skipped/deferred, 0 failures.
DB: +24 complete, +66 deferred.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DirJwtStore"/> expiration, limits, LRU eviction,
|
||||
/// reload, TTL and notification behaviour.
|
||||
/// Mirrors server/dirstore_test.go tests 285–296.
|
||||
/// </summary>
|
||||
[Collection("DirectoryStoreTests")]
|
||||
public sealed class DirectoryStoreTests : IDisposable
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Counter for unique public-key names
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static int _counter;
|
||||
|
||||
private static string NextKey() =>
|
||||
$"ACCT{Interlocked.Increment(ref _counter):D8}";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Temp directory management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
private string MakeTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dirstore_" + Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(dir);
|
||||
_tempDirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers — fake JWT construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal fake JWT string: header.payload.signature
|
||||
/// where the payload contains "exp", "iat" and "jti" claims.
|
||||
/// </summary>
|
||||
private static string MakeFakeJwt(
|
||||
long expUnixSeconds,
|
||||
long iatUnixSeconds = 0,
|
||||
string? jti = null)
|
||||
{
|
||||
if (iatUnixSeconds == 0)
|
||||
iatUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
jti ??= Guid.NewGuid().ToString("N");
|
||||
|
||||
var payloadObj = expUnixSeconds > 0
|
||||
? $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds},\"exp\":{expUnixSeconds}}}"
|
||||
: $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds}}}";
|
||||
|
||||
var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"ed25519-nkey\",\"typ\":\"JWT\"}"));
|
||||
var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadObj));
|
||||
var sigB64 = Base64UrlEncode(new byte[64]); // dummy 64-byte signature
|
||||
return $"{headerB64}.{payloadB64}.{sigB64}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rounds a <see cref="DateTimeOffset"/> to the nearest whole second,
|
||||
/// mirroring Go's <c>time.Now().Round(time.Second)</c>.
|
||||
/// </summary>
|
||||
private static DateTimeOffset RoundToSecond(DateTimeOffset dt) =>
|
||||
dt.Millisecond >= 500
|
||||
? new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset).AddSeconds(1)
|
||||
: new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);
|
||||
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and saves a test account JWT in the store.
|
||||
/// <paramref name="expSec"/> == 0 means no expiration.
|
||||
/// Returns the saved JWT string.
|
||||
/// </summary>
|
||||
private static string CreateTestAccount(DirJwtStore store, string pubKey, int expSec)
|
||||
{
|
||||
long exp = expSec > 0
|
||||
// Round to the nearest second first (mirrors Go's time.Now().Round(time.Second).Add(...).Unix()),
|
||||
// ensuring the expiry is at a whole-second boundary and avoiding sub-second truncation races.
|
||||
? RoundToSecond(DateTimeOffset.UtcNow).AddSeconds(expSec).ToUnixTimeSeconds()
|
||||
: 0;
|
||||
var theJwt = MakeFakeJwt(exp);
|
||||
store.SaveAcc(pubKey, theJwt);
|
||||
return theJwt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts non-deleted .jwt files in <paramref name="dir"/> recursively.
|
||||
/// </summary>
|
||||
private static int CountJwtFiles(string dir) =>
|
||||
Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
|
||||
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:285 — TestExpiration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:285
|
||||
public async Task Expiration_ExpiredAccountIsRemovedByBackground()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var hBegin = store.Hash();
|
||||
|
||||
// Add one account that should NOT expire (100-second TTL).
|
||||
var keyNoExp = NextKey();
|
||||
CreateTestAccount(store, keyNoExp, 100);
|
||||
var hNoExp = store.Hash();
|
||||
hNoExp.ShouldNotBe(hBegin);
|
||||
|
||||
// Add one account that should expire in ~1 second.
|
||||
var keyExp = NextKey();
|
||||
CreateTestAccount(store, keyExp, 1);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Wait up to 4 s for the expired file to vanish.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(4);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
if (CountJwtFiles(dir) == 1)
|
||||
break;
|
||||
}
|
||||
|
||||
CountJwtFiles(dir).ShouldBe(1, "expired account should be removed");
|
||||
|
||||
// Hash after expiry should equal hash after adding only the non-expiring key.
|
||||
var lh = store.Hash();
|
||||
lh.ShouldBe(hNoExp);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:286 — TestLimit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:286
|
||||
public void Limit_LruEvictsOldestEntries()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||||
limit: 5,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var h = store.Hash();
|
||||
|
||||
// Update the first account 10 times — should remain as 1 entry.
|
||||
var firstKey = NextKey();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
CreateTestAccount(store, firstKey, 50);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
}
|
||||
|
||||
// Add 10 more new accounts — limit is 5, LRU eviction kicks in.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var k = NextKey();
|
||||
CreateTestAccount(store, k, i + 1); // short but non-zero expiry
|
||||
var nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
}
|
||||
|
||||
// After all adds, only 5 files should remain.
|
||||
CountJwtFiles(dir).ShouldBe(5);
|
||||
|
||||
// The first account should have been evicted.
|
||||
File.Exists(Path.Combine(dir, firstKey + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Updating the first account again should succeed (limit allows eviction).
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
CreateTestAccount(store, firstKey, 50);
|
||||
CountJwtFiles(dir).ShouldBe(5);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:287 — TestLimitNoEvict
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:287
|
||||
public async Task LimitNoEvict_StoreFullThrowsOnNewKey()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 2,
|
||||
evictOnLimit: false,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key1 = NextKey();
|
||||
var key2 = NextKey();
|
||||
var key3 = NextKey();
|
||||
|
||||
CreateTestAccount(store, key1, 100);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
|
||||
// key2 expires in 1 second
|
||||
CreateTestAccount(store, key2, 1);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
var hashBefore = store.Hash();
|
||||
|
||||
// Attempting to add key3 should throw (limit=2, no evict).
|
||||
var exp3 = DateTimeOffset.UtcNow.AddSeconds(100).ToUnixTimeSeconds();
|
||||
var jwt3 = MakeFakeJwt(exp3);
|
||||
Should.Throw<InvalidOperationException>(() => store.SaveAcc(key3, jwt3));
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Hash should not change after the failed add.
|
||||
store.Hash().ShouldBe(hashBefore);
|
||||
|
||||
// Wait for key2 to expire.
|
||||
await Task.Delay(2200);
|
||||
|
||||
// Now adding key3 should succeed.
|
||||
store.SaveAcc(key3, jwt3);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:288 — TestLruLoad
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:288
|
||||
public void LruLoad_LoadReordersLru()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key1 = NextKey();
|
||||
var key2 = NextKey();
|
||||
var key3 = NextKey();
|
||||
|
||||
CreateTestAccount(store, key1, 10);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
CreateTestAccount(store, key2, 10);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Access key1 — makes it the most-recently-used.
|
||||
store.LoadAcc(key1);
|
||||
|
||||
// Adding key3 should evict key2 (oldest), not key1.
|
||||
CreateTestAccount(store, key3, 10);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:289 — TestLruVolume
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:289
|
||||
public void LruVolume_ContinuousReplacementsAlwaysEvictsOldest()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
const int ReplaceCnt = 200; // must be > 2 due to the invariant
|
||||
var keys = new string[ReplaceCnt];
|
||||
|
||||
keys[0] = NextKey();
|
||||
CreateTestAccount(store, keys[0], 10000);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
|
||||
keys[1] = NextKey();
|
||||
CreateTestAccount(store, keys[1], 10000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
for (var i = 2; i < ReplaceCnt; i++)
|
||||
{
|
||||
keys[i] = NextKey();
|
||||
CreateTestAccount(store, keys[i], 10000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// key two positions back should have been evicted.
|
||||
File.Exists(Path.Combine(dir, keys[i - 2] + ".jwt")).ShouldBeFalse(
|
||||
$"key[{i - 2}] should be evicted after adding key[{i}]");
|
||||
// key one position back should still be present.
|
||||
File.Exists(Path.Combine(dir, keys[i - 1] + ".jwt")).ShouldBeTrue();
|
||||
// current key should be present.
|
||||
File.Exists(Path.Combine(dir, keys[i] + ".jwt")).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:290 — TestLru
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:290
|
||||
public async Task Lru_EvictsAndExpires()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key1 = NextKey();
|
||||
var key2 = NextKey();
|
||||
var key3 = NextKey();
|
||||
|
||||
CreateTestAccount(store, key1, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
CreateTestAccount(store, key2, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Adding key3 should evict key1 (oldest).
|
||||
CreateTestAccount(store, key3, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||||
|
||||
// Update key2 → moves it to MRU. key3 becomes LRU.
|
||||
CreateTestAccount(store, key2, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Recreate key1 (which was evicted) → evicts key3.
|
||||
CreateTestAccount(store, key1, 1); // expires in 1 s
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Let key1 expire (1 s + 1 s buffer for rounding).
|
||||
await Task.Delay(2200);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Recreate key3 — no eviction needed, slot is free.
|
||||
CreateTestAccount(store, key3, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:291 — TestReload
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:291
|
||||
public void Reload_DetectsFilesAddedAndRemoved()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
var notificationChan = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: pk => notificationChan.Enqueue(pk));
|
||||
|
||||
CountJwtFiles(dir).ShouldBe(0);
|
||||
var emptyHash = new byte[32];
|
||||
store.Hash().ShouldBe(emptyHash);
|
||||
|
||||
var files = new List<string>();
|
||||
|
||||
// Add 5 accounts by writing to disk directly, then Reload().
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var key = NextKey();
|
||||
var exp = DateTimeOffset.UtcNow.AddSeconds(10000).ToUnixTimeSeconds();
|
||||
var jwt = MakeFakeJwt(exp);
|
||||
var path = Path.Combine(dir, key + ".jwt");
|
||||
File.WriteAllText(path, jwt);
|
||||
files.Add(path);
|
||||
|
||||
store.Reload();
|
||||
|
||||
// Wait briefly for notification.
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(500);
|
||||
while (notificationChan.IsEmpty && DateTime.UtcNow < deadline)
|
||||
Thread.Sleep(10);
|
||||
notificationChan.TryDequeue(out _);
|
||||
|
||||
CountJwtFiles(dir).ShouldBe(i + 1);
|
||||
store.Hash().ShouldNotBe(emptyHash);
|
||||
|
||||
var packed = store.Pack(-1);
|
||||
packed.Split('\n').Length.ShouldBe(i + 1);
|
||||
}
|
||||
|
||||
// Now remove files one by one.
|
||||
foreach (var f in files)
|
||||
{
|
||||
var hash = store.Hash();
|
||||
hash.ShouldNotBe(emptyHash);
|
||||
File.Delete(f);
|
||||
store.Reload();
|
||||
CountJwtFiles(dir).ShouldBe(files.Count - files.IndexOf(f) - 1);
|
||||
}
|
||||
|
||||
store.Hash().ShouldBe(emptyHash);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:292 — TestExpirationUpdate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:292
|
||||
public async Task ExpirationUpdate_UpdatingExpirationExtendsTTL()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key = NextKey();
|
||||
var h = store.Hash();
|
||||
|
||||
// Save account with no expiry.
|
||||
CreateTestAccount(store, key, 0);
|
||||
var nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(1); // should NOT have expired (no exp claim)
|
||||
|
||||
// Save same account with 2-second expiry.
|
||||
CreateTestAccount(store, key, 2);
|
||||
nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(1); // not expired yet
|
||||
|
||||
// Save with no expiry again — resets expiry on that account.
|
||||
CreateTestAccount(store, key, 0);
|
||||
nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(1); // still NOT expired
|
||||
|
||||
// Now save with 1-second expiry.
|
||||
CreateTestAccount(store, key, 1);
|
||||
nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(0); // should be expired now
|
||||
|
||||
var empty = new byte[32];
|
||||
store.Hash().ShouldBe(empty);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:293 — TestTTL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:293
|
||||
public async Task TTL_AccessResetsExpirationOnStore()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
var key = NextKey();
|
||||
|
||||
// TTL = 200 ms. Each access (Load or Save) should reset expiry.
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.FromMilliseconds(200),
|
||||
changeNotification: null);
|
||||
|
||||
CreateTestAccount(store, key, 0);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
|
||||
// Access every 110 ms — should prevent expiration.
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
await Task.Delay(110);
|
||||
store.LoadAcc(key); // TTL reset via Load
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
}
|
||||
|
||||
// Stop accessing — wait for expiration.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(3);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
if (CountJwtFiles(dir) == 0)
|
||||
return; // expired as expected
|
||||
}
|
||||
|
||||
Assert.Fail("JWT should have expired by now via TTL");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:294 — TestRemove
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:294
|
||||
public void Remove_RespectsDeleteType()
|
||||
{
|
||||
foreach (var (deleteType, expectedJwt, expectedDeleted) in new[]
|
||||
{
|
||||
(JwtDeleteType.HardDelete, 0, 0),
|
||||
(JwtDeleteType.RenameDeleted, 0, 1),
|
||||
(JwtDeleteType.NoDelete, 1, 0),
|
||||
})
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: deleteType,
|
||||
expireCheck: TimeSpan.Zero,
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key = NextKey();
|
||||
CreateTestAccount(store, key, 0);
|
||||
CountJwtFiles(dir).ShouldBe(1, $"deleteType={deleteType}: should have 1 jwt before delete");
|
||||
|
||||
// For HardDelete and RenameDeleted the store must allow Delete.
|
||||
// For NoDelete, Delete should throw.
|
||||
if (deleteType == JwtDeleteType.NoDelete)
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(() => store.Delete(key),
|
||||
$"deleteType={deleteType}: should throw on delete");
|
||||
}
|
||||
else
|
||||
{
|
||||
store.Delete(key);
|
||||
}
|
||||
|
||||
// Count .jwt files (not .jwt.deleted).
|
||||
var jwtFiles = Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
|
||||
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
|
||||
jwtFiles.ShouldBe(expectedJwt, $"deleteType={deleteType}: unexpected jwt count");
|
||||
|
||||
// Count .jwt.deleted files.
|
||||
var deletedFiles = Directory.GetFiles(dir, "*.jwt.deleted", SearchOption.AllDirectories).Length;
|
||||
deletedFiles.ShouldBe(expectedDeleted, $"deleteType={deleteType}: unexpected deleted count");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:295 — TestNotificationOnPack
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:295
|
||||
public void NotificationOnPack_MergeFiresChangedCallback()
|
||||
{
|
||||
// Pre-populate a store with 4 accounts, pack it, then Merge into new stores.
|
||||
// Each Merge should fire the change notification for every key.
|
||||
const int JwtCount = 4;
|
||||
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
|
||||
var dirPack = MakeTempDir();
|
||||
var keys = new string[JwtCount];
|
||||
var jwts = new string[JwtCount];
|
||||
|
||||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
using var packStore = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dirPack, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: infDur,
|
||||
limit: 0,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: pk => notifications.Enqueue(pk));
|
||||
|
||||
for (var i = 0; i < JwtCount; i++)
|
||||
{
|
||||
keys[i] = NextKey();
|
||||
jwts[i] = MakeFakeJwt(0); // no expiry
|
||||
packStore.SaveAcc(keys[i], jwts[i]);
|
||||
}
|
||||
|
||||
// Drain initial notifications.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (notifications.Count < JwtCount && DateTime.UtcNow < deadline)
|
||||
Thread.Sleep(10);
|
||||
while (notifications.TryDequeue(out _)) { }
|
||||
|
||||
var msg = packStore.Pack(-1);
|
||||
var hash = packStore.Hash();
|
||||
|
||||
// Merge into new stores (sharded and unsharded).
|
||||
foreach (var shard in new[] { true, false, true, false })
|
||||
{
|
||||
var dirMerge = MakeTempDir();
|
||||
var mergeNotifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
using var mergeStore = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dirMerge, shard: shard, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: infDur,
|
||||
limit: 0,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: pk => mergeNotifications.Enqueue(pk));
|
||||
|
||||
mergeStore.Merge(msg);
|
||||
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
|
||||
|
||||
// Hashes must match.
|
||||
packStore.Hash().ShouldBe(hash);
|
||||
|
||||
// Wait for JwtCount notifications.
|
||||
deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (mergeNotifications.Count < JwtCount && DateTime.UtcNow < deadline)
|
||||
Thread.Sleep(10);
|
||||
mergeNotifications.Count.ShouldBeGreaterThanOrEqualTo(JwtCount);
|
||||
|
||||
// Double-merge should produce no extra file changes.
|
||||
while (mergeNotifications.TryDequeue(out _)) { }
|
||||
mergeStore.Merge(msg);
|
||||
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
|
||||
Thread.Sleep(50);
|
||||
mergeNotifications.IsEmpty.ShouldBeTrue("no new notifications on re-merge of identical JWTs");
|
||||
|
||||
msg = mergeStore.Pack(-1);
|
||||
}
|
||||
|
||||
// All original JWTs can still be loaded from the last pack.
|
||||
for (var i = 0; i < JwtCount; i++)
|
||||
{
|
||||
var found = msg.Contains(keys[i] + "|" + jwts[i]);
|
||||
found.ShouldBeTrue($"key {keys[i]} should be in packed message");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:296 — TestNotificationOnPackWalk
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:296
|
||||
public void NotificationOnPackWalk_PropagatesAcrossChainOfStores()
|
||||
{
|
||||
const int StoreCnt = 5;
|
||||
const int KeyCnt = 50;
|
||||
const int IterCnt = 4; // reduced from Go's 8 to keep test fast
|
||||
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
|
||||
|
||||
var stores = new DirJwtStore[StoreCnt];
|
||||
var dirs = new string[StoreCnt];
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < StoreCnt; i++)
|
||||
{
|
||||
dirs[i] = MakeTempDir();
|
||||
stores[i] = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dirs[i], shard: true, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: infDur,
|
||||
limit: 0,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
}
|
||||
|
||||
for (var iter = 0; iter < IterCnt; iter++)
|
||||
{
|
||||
// Fill store[0] with KeyCnt new accounts.
|
||||
for (var j = 0; j < KeyCnt; j++)
|
||||
{
|
||||
var k = NextKey();
|
||||
var jwt = MakeFakeJwt(0);
|
||||
stores[0].SaveAcc(k, jwt);
|
||||
}
|
||||
|
||||
// Propagate via PackWalk from store[n] → store[n+1].
|
||||
for (var j = 0; j < StoreCnt - 1; j++)
|
||||
{
|
||||
stores[j].PackWalk(3, partial => stores[j + 1].Merge(partial));
|
||||
}
|
||||
|
||||
// Verify all adjacent store hashes match.
|
||||
for (var j = 0; j < StoreCnt - 1; j++)
|
||||
{
|
||||
stores[j].Hash().ShouldBe(stores[j + 1].Hash(),
|
||||
$"stores[{j}] and stores[{j + 1}] should have matching hashes after iteration {iter}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Mirrors server/jetstream_batching_test.go in the NATS server Go source.
|
||||
// ALL tests in this file are deferred: they all use createJetStreamClusterExplicit()
|
||||
// or RunBasicJetStreamServer() and require a running JetStream cluster/server.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for JetStream atomic batch publishing.
|
||||
/// Mirrors server/jetstream_batching_test.go.
|
||||
/// All tests are deferred pending JetStream server infrastructure.
|
||||
/// </summary>
|
||||
public sealed class JetStreamBatchingTests
|
||||
{
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:716
|
||||
public void JetStreamAtomicBatchPublish_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:717
|
||||
public void JetStreamAtomicBatchPublishEmptyAck_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:718
|
||||
public void JetStreamAtomicBatchPublishCommitEob_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:719
|
||||
public void JetStreamAtomicBatchPublishLimits_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:720
|
||||
public void JetStreamAtomicBatchPublishDedupeNotAllowed_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:721
|
||||
public void JetStreamAtomicBatchPublishSourceAndMirror_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:722
|
||||
public void JetStreamAtomicBatchPublishCleanup_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:723
|
||||
public void JetStreamAtomicBatchPublishConfigOpts_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:724
|
||||
public void JetStreamAtomicBatchPublishDenyHeaders_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:725
|
||||
public void JetStreamAtomicBatchPublishStageAndCommit_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:726
|
||||
public void JetStreamAtomicBatchPublishHighLevelRollback_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:727
|
||||
public void JetStreamAtomicBatchPublishExpectedPerSubject_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:728
|
||||
public void JetStreamAtomicBatchPublishSingleServerRecovery_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:729
|
||||
public void JetStreamAtomicBatchPublishSingleServerRecoveryCommitEob_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:730
|
||||
public void JetStreamAtomicBatchPublishEncode_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:731
|
||||
public void JetStreamAtomicBatchPublishProposeOne_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:732
|
||||
public void JetStreamAtomicBatchPublishProposeMultiple_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:733
|
||||
public void JetStreamAtomicBatchPublishProposeOnePartialBatch_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:734
|
||||
public void JetStreamAtomicBatchPublishProposeMultiplePartialBatches_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:735
|
||||
public void JetStreamAtomicBatchPublishContinuousBatchesStillMoveAppliedUp_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:736
|
||||
public void JetStreamAtomicBatchPublishPartiallyAppliedBatchOnRecovery_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:737
|
||||
public void JetStreamRollupIsolatedRead_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:738
|
||||
public void JetStreamAtomicBatchPublishAdvisories_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:739
|
||||
public void JetStreamAtomicBatchPublishExpectedSeq_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:740
|
||||
public void JetStreamAtomicBatchPublishPartialBatchInSharedAppendEntry_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:741
|
||||
public void JetStreamAtomicBatchPublishRejectPartialBatchOnLeaderChange_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:742
|
||||
public void JetStreamAtomicBatchPublishPersistModeAsync_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:743
|
||||
public void JetStreamAtomicBatchPublishExpectedLastSubjectSequence_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:744
|
||||
public void JetStreamAtomicBatchPublishCommitUnsupported_RequiresRunningServer() { }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2020-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Mirrors server/jetstream_errors_test.go in the NATS server Go source.
|
||||
//
|
||||
// All 4 tests are deferred:
|
||||
// T:1381 — TestIsNatsErr: uses IsNatsErr(error, ...) where the Go version accepts
|
||||
// arbitrary error interface values (including plain errors.New("x") which
|
||||
// evaluates to false). The .NET JsApiErrors.IsNatsError only accepts JsApiError?
|
||||
// and the "NewJS*" factory constructors (NewJSRestoreSubscribeFailedError etc.)
|
||||
// that populate Description templates from tags have not been ported yet.
|
||||
// T:1382 — TestApiError_Error: uses ApiErrors[JSClusterNotActiveErr].Error() — the Go
|
||||
// ApiErrors map and per-error .Error() method (returns "description (errCode)")
|
||||
// differs from the .NET JsApiErrors.ClusterNotActive.ToString() convention.
|
||||
// T:1383 — TestApiError_NewWithTags: uses NewJSRestoreSubscribeFailedError with tag
|
||||
// substitution — factory constructors not yet ported.
|
||||
// T:1384 — TestApiError_NewWithUnless: uses NewJSStreamRestoreError, Unless() helper,
|
||||
// NewJSPeerRemapError — not yet ported.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for JetStream API error types and IsNatsErr helper.
|
||||
/// Mirrors server/jetstream_errors_test.go.
|
||||
/// All tests deferred pending port of Go factory constructors and tag-substitution system.
|
||||
/// </summary>
|
||||
public sealed class JetStreamErrorsTests
|
||||
{
|
||||
[Fact(Skip = "deferred: NewJS* factory constructors and IsNatsErr(error) not yet ported")] // T:1381
|
||||
public void IsNatsErr_ShouldSucceed() { }
|
||||
|
||||
[Fact(Skip = "deferred: ApiErrors map and .Error() method not yet ported")] // T:1382
|
||||
public void ApiError_Error_ShouldSucceed() { }
|
||||
|
||||
[Fact(Skip = "deferred: NewJSRestoreSubscribeFailedError with tag substitution not yet ported")] // T:1383
|
||||
public void ApiError_NewWithTags_ShouldSucceed() { }
|
||||
|
||||
[Fact(Skip = "deferred: NewJSStreamRestoreError / Unless() helper not yet ported")] // T:1384
|
||||
public void ApiError_NewWithUnless_ShouldSucceed() { }
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Copyright 2024-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Mirrors server/jetstream_versioning_test.go in the NATS server Go source.
|
||||
|
||||
using Shouldly;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for JetStream API level versioning helpers.
|
||||
/// Mirrors server/jetstream_versioning_test.go.
|
||||
/// Tests 1803–1808 (TestJetStreamMetadataMutations, TestJetStreamMetadataStreamRestoreAndRestart,
|
||||
/// TestJetStreamMetadataStreamRestoreAndRestartCluster, TestJetStreamApiErrorOnRequiredApiLevel,
|
||||
/// TestJetStreamApiErrorOnRequiredApiLevelDirectGet, TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg)
|
||||
/// all require a running JetStream server and are deferred.
|
||||
/// </summary>
|
||||
public sealed class JetStreamVersioningTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers (mirrors module-level helpers in Go test file)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Dictionary<string, string> MetadataAtLevel(string featureLevel) =>
|
||||
new() { [JetStreamVersioning.JsRequiredLevelMetadataKey] = featureLevel };
|
||||
|
||||
private static Dictionary<string, string> MetadataPrevious() =>
|
||||
new() { [JetStreamVersioning.JsRequiredLevelMetadataKey] = "previous-level" };
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1791 — TestGetAndSupportsRequiredApiLevel
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1791
|
||||
public void GetAndSupportsRequiredApiLevel_VariousInputs_ReturnsExpected()
|
||||
{
|
||||
// getRequiredApiLevel
|
||||
JetStreamVersioning.GetRequiredApiLevel(null).ShouldBe(string.Empty);
|
||||
JetStreamVersioning.GetRequiredApiLevel(new Dictionary<string, string>()).ShouldBe(string.Empty);
|
||||
JetStreamVersioning.GetRequiredApiLevel(MetadataAtLevel("1")).ShouldBe("1");
|
||||
JetStreamVersioning.GetRequiredApiLevel(MetadataAtLevel("text")).ShouldBe("text");
|
||||
|
||||
// supportsRequiredApiLevel
|
||||
JetStreamVersioning.SupportsRequiredApiLevel(null).ShouldBeTrue();
|
||||
JetStreamVersioning.SupportsRequiredApiLevel(new Dictionary<string, string>()).ShouldBeTrue();
|
||||
JetStreamVersioning.SupportsRequiredApiLevel(MetadataAtLevel("1")).ShouldBeTrue();
|
||||
JetStreamVersioning.SupportsRequiredApiLevel(
|
||||
MetadataAtLevel(JetStreamVersioning.JsApiLevel.ToString())).ShouldBeTrue();
|
||||
JetStreamVersioning.SupportsRequiredApiLevel(MetadataAtLevel("text")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1792 — TestJetStreamSetStaticStreamMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1792
|
||||
public void SetStaticStreamMetadata_VariousConfigs_SetsCorrectApiLevel()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
("empty", new StreamConfig(), "0"),
|
||||
("overwrite-user-provided", new StreamConfig { Metadata = MetadataPrevious() }, "0"),
|
||||
("AllowMsgTTL", new StreamConfig { AllowMsgTTL = true }, "1"),
|
||||
("SubjectDeleteMarkerTTL", new StreamConfig { SubjectDeleteMarkerTTL = TimeSpan.FromSeconds(1) }, "1"),
|
||||
("AllowMsgCounter", new StreamConfig { AllowMsgCounter = true }, "2"),
|
||||
("AllowAtomicPublish", new StreamConfig { AllowAtomicPublish = true }, "2"),
|
||||
("AllowMsgSchedules", new StreamConfig { AllowMsgSchedules = true }, "2"),
|
||||
("AsyncPersistMode", new StreamConfig { PersistMode = PersistModeType.AsyncPersistMode }, "2"),
|
||||
};
|
||||
|
||||
foreach (var (desc, cfg, expectedLevel) in cases)
|
||||
{
|
||||
JetStreamVersioning.SetStaticStreamMetadata(cfg);
|
||||
var level = cfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey];
|
||||
level.ShouldBe(expectedLevel, $"case: {desc}");
|
||||
|
||||
// Ensure we do not exceed the server API level.
|
||||
int.Parse(level).ShouldBeLessThanOrEqualTo(JetStreamVersioning.JsApiLevel,
|
||||
customMessage: $"case: {desc}");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1793 — TestJetStreamSetStaticStreamMetadataRemoveDynamicFields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1793
|
||||
public void SetStaticStreamMetadata_RemovesDynamicFields()
|
||||
{
|
||||
var cfg = new StreamConfig
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
|
||||
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
|
||||
}
|
||||
};
|
||||
|
||||
JetStreamVersioning.SetStaticStreamMetadata(cfg);
|
||||
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
cfg.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1794 — TestJetStreamSetDynamicStreamMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1794
|
||||
public void SetDynamicStreamMetadata_DoesNotMutateOriginal_AddsVersionFields()
|
||||
{
|
||||
var cfg = new StreamConfig { Metadata = MetadataAtLevel("0") };
|
||||
var newCfg = JetStreamVersioning.SetDynamicStreamMetadata(cfg);
|
||||
|
||||
// Original must NOT have dynamic fields.
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
|
||||
// New copy must have dynamic fields.
|
||||
newCfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
newCfg.Metadata[JetStreamVersioning.JsServerVersionMetadataKey].ShouldBe(ServerConstants.Version);
|
||||
newCfg.Metadata[JetStreamVersioning.JsServerLevelMetadataKey]
|
||||
.ShouldBe(JetStreamVersioning.JsApiLevel.ToString());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1795 — TestJetStreamCopyStreamMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1795
|
||||
public void CopyStreamMetadata_VariousScenarios_CopiesRequiredLevelKey()
|
||||
{
|
||||
// no-previous-ignore: when prevCfg is null, key must be absent
|
||||
var cfg1 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg1, null);
|
||||
(cfg1.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
|
||||
|
||||
// nil-previous-metadata-ignore: prevCfg has null Metadata
|
||||
var cfg2 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg2, new StreamConfig { Metadata = null });
|
||||
(cfg2.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
|
||||
|
||||
// nil-current-metadata-ignore: cfg has null Metadata — should not throw
|
||||
var cfg3 = new StreamConfig { Metadata = null };
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg3, new StreamConfig { Metadata = MetadataPrevious() });
|
||||
cfg3.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
|
||||
|
||||
// copy-previous: key from prevCfg is copied into cfg
|
||||
var cfg4 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg4, new StreamConfig { Metadata = MetadataPrevious() });
|
||||
cfg4.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
|
||||
|
||||
// delete-missing-fields: prevCfg has empty metadata dict → key absent in cfg
|
||||
var cfg5 = new StreamConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg5, new StreamConfig { Metadata = new Dictionary<string, string>() });
|
||||
(cfg5.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1796 — TestJetStreamCopyStreamMetadataRemoveDynamicFields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1796
|
||||
public void CopyStreamMetadata_RemovesDynamicFields()
|
||||
{
|
||||
// Copy from null prevCfg — dynamic fields should be removed and key absent.
|
||||
var cfg = new StreamConfig
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
|
||||
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
|
||||
}
|
||||
};
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg, null);
|
||||
cfg.Metadata.ShouldBeNull(); // all entries removed → null'd
|
||||
|
||||
// Copy from prevCfg with req-level → dynamic fields removed, req-level preserved.
|
||||
var cfg2 = new StreamConfig
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
|
||||
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
|
||||
}
|
||||
};
|
||||
var prev = new StreamConfig { Metadata = MetadataAtLevel("0") };
|
||||
JetStreamVersioning.CopyStreamMetadata(cfg2, prev);
|
||||
cfg2.Metadata.ShouldNotBeNull();
|
||||
cfg2.Metadata!.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
cfg2.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
cfg2.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1797 — TestJetStreamSetStaticConsumerMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1797
|
||||
public void SetStaticConsumerMetadata_VariousConfigs_SetsCorrectApiLevel()
|
||||
{
|
||||
var pauseUntil = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc); // Unix(0, 0) = epoch+1s
|
||||
var pauseUntilZero = default(DateTime);
|
||||
|
||||
var cases = new[]
|
||||
{
|
||||
("empty", new ConsumerConfig(), "0"),
|
||||
("overwrite-user-provided", new ConsumerConfig { Metadata = MetadataPrevious() }, "0"),
|
||||
("PauseUntil/zero", new ConsumerConfig { PauseUntil = pauseUntilZero }, "0"),
|
||||
("PauseUntil", new ConsumerConfig { PauseUntil = pauseUntil }, "1"),
|
||||
("Pinned", new ConsumerConfig { PriorityPolicy = PriorityPolicy.PriorityPinnedClient,
|
||||
PriorityGroups = new[] { "a" } }, "1"),
|
||||
};
|
||||
|
||||
foreach (var (desc, cfg, expectedLevel) in cases)
|
||||
{
|
||||
JetStreamVersioning.SetStaticConsumerMetadata(cfg);
|
||||
var level = cfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey];
|
||||
level.ShouldBe(expectedLevel, $"case: {desc}");
|
||||
|
||||
int.Parse(level).ShouldBeLessThanOrEqualTo(JetStreamVersioning.JsApiLevel,
|
||||
customMessage: $"case: {desc}");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1798 — TestJetStreamSetStaticConsumerMetadataRemoveDynamicFields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1798
|
||||
public void SetStaticConsumerMetadata_RemovesDynamicFields()
|
||||
{
|
||||
var cfg = new ConsumerConfig
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
|
||||
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
|
||||
}
|
||||
};
|
||||
|
||||
JetStreamVersioning.SetStaticConsumerMetadata(cfg);
|
||||
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
cfg.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1799 — TestJetStreamSetDynamicConsumerMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1799
|
||||
public void SetDynamicConsumerMetadata_DoesNotMutateOriginal_AddsVersionFields()
|
||||
{
|
||||
var cfg = new ConsumerConfig { Metadata = MetadataAtLevel("0") };
|
||||
var newCfg = JetStreamVersioning.SetDynamicConsumerMetadata(cfg);
|
||||
|
||||
// Original must NOT have dynamic fields.
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
cfg.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
|
||||
// New copy must have dynamic fields.
|
||||
newCfg.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
newCfg.Metadata[JetStreamVersioning.JsServerVersionMetadataKey].ShouldBe(ServerConstants.Version);
|
||||
newCfg.Metadata[JetStreamVersioning.JsServerLevelMetadataKey]
|
||||
.ShouldBe(JetStreamVersioning.JsApiLevel.ToString());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1800 — TestJetStreamSetDynamicConsumerInfoMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1800
|
||||
public void SetDynamicConsumerInfoMetadata_DoesNotMutateOriginal_AddsVersionFields()
|
||||
{
|
||||
var ci = new ConsumerInfo { Config = new ConsumerConfig { Metadata = MetadataAtLevel("0") } };
|
||||
var newCi = JetStreamVersioning.SetDynamicConsumerInfoMetadata(ci);
|
||||
|
||||
// Configs must not be reference-equal (we got a new object).
|
||||
ReferenceEquals(ci, newCi).ShouldBeFalse();
|
||||
|
||||
// Original config must NOT have dynamic fields.
|
||||
ci.Config!.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
ci.Config.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
|
||||
// New config must have dynamic fields.
|
||||
newCi.Config!.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
newCi.Config.Metadata[JetStreamVersioning.JsServerVersionMetadataKey].ShouldBe(ServerConstants.Version);
|
||||
newCi.Config.Metadata[JetStreamVersioning.JsServerLevelMetadataKey]
|
||||
.ShouldBe(JetStreamVersioning.JsApiLevel.ToString());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1801 — TestJetStreamCopyConsumerMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1801
|
||||
public void CopyConsumerMetadata_VariousScenarios_CopiesRequiredLevelKey()
|
||||
{
|
||||
// no-previous-ignore
|
||||
var cfg1 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg1, null);
|
||||
(cfg1.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
|
||||
|
||||
// nil-previous-metadata-ignore
|
||||
var cfg2 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg2, new ConsumerConfig { Metadata = null });
|
||||
(cfg2.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
|
||||
|
||||
// nil-current-metadata-ignore
|
||||
var cfg3 = new ConsumerConfig { Metadata = null };
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg3, new ConsumerConfig { Metadata = MetadataPrevious() });
|
||||
cfg3.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
|
||||
|
||||
// copy-previous
|
||||
var cfg4 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg4, new ConsumerConfig { Metadata = MetadataPrevious() });
|
||||
cfg4.Metadata![JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("previous-level");
|
||||
|
||||
// delete-missing-fields
|
||||
var cfg5 = new ConsumerConfig { Metadata = MetadataAtLevel("-1") };
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg5,
|
||||
new ConsumerConfig { Metadata = new Dictionary<string, string>() });
|
||||
(cfg5.Metadata?.ContainsKey(JetStreamVersioning.JsRequiredLevelMetadataKey) ?? false).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1802 — TestJetStreamCopyConsumerMetadataRemoveDynamicFields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:1802
|
||||
public void CopyConsumerMetadata_RemovesDynamicFields()
|
||||
{
|
||||
// Copy from null prevCfg → dynamic removed, key absent.
|
||||
var cfg = new ConsumerConfig
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
|
||||
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
|
||||
}
|
||||
};
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg, null);
|
||||
cfg.Metadata.ShouldBeNull();
|
||||
|
||||
// Copy from prevCfg with req-level → dynamic removed, req-level preserved.
|
||||
var cfg2 = new ConsumerConfig
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[JetStreamVersioning.JsServerVersionMetadataKey] = "dynamic-version",
|
||||
[JetStreamVersioning.JsServerLevelMetadataKey] = "dynamic-level",
|
||||
}
|
||||
};
|
||||
var prev = new ConsumerConfig { Metadata = MetadataAtLevel("0") };
|
||||
JetStreamVersioning.CopyConsumerMetadata(cfg2, prev);
|
||||
cfg2.Metadata.ShouldNotBeNull();
|
||||
cfg2.Metadata!.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey);
|
||||
cfg2.Metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey);
|
||||
cfg2.Metadata[JetStreamVersioning.JsRequiredLevelMetadataKey].ShouldBe("0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1803 — TestJetStreamMetadataMutations — deferred: requires RunBasicJetStreamServer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream server")] // T:1803
|
||||
public void JetStreamMetadataMutations_RequiresRunningServer() { }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1804 — TestJetStreamMetadataStreamRestoreAndRestart — deferred
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream server")] // T:1804
|
||||
public void JetStreamMetadataStreamRestoreAndRestart_RequiresRunningServer() { }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1805 — TestJetStreamMetadataStreamRestoreAndRestartCluster — deferred
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:1805
|
||||
public void JetStreamMetadataStreamRestoreAndRestartCluster_RequiresRunningServer() { }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1806 — TestJetStreamApiErrorOnRequiredApiLevel — deferred
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream server")] // T:1806
|
||||
public void JetStreamApiErrorOnRequiredApiLevel_RequiresRunningServer() { }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1807 — TestJetStreamApiErrorOnRequiredApiLevelDirectGet — deferred
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream server")] // T:1807
|
||||
public void JetStreamApiErrorOnRequiredApiLevelDirectGet_RequiresRunningServer() { }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:1808 — TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg — deferred
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream server")] // T:1808
|
||||
public void JetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg_RequiresRunningServer() { }
|
||||
}
|
||||
Reference in New Issue
Block a user