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:
@@ -624,7 +624,7 @@ public sealed class DirJwtStore : IDisposable
|
||||
/// Deletes the JWT for <paramref name="publicKey"/> according to <see cref="_deleteType"/>.
|
||||
/// Mirrors Go <c>DirJWTStore.delete</c>.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>setStaticStreamMetadata</c>.
|
||||
/// </summary>
|
||||
public static void SetStaticStreamMetadata(StreamConfig cfg)
|
||||
{
|
||||
cfg.Metadata ??= new Dictionary<string, string>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow copy of the stream config with dynamic versioning fields added to a new
|
||||
/// metadata dictionary. Does not mutate <paramref name="cfg"/>.
|
||||
/// Mirrors <c>setDynamicStreamMetadata</c>.
|
||||
/// </summary>
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the required-level versioning field from <paramref name="prevCfg"/> into
|
||||
/// <paramref name="cfg"/>, removing dynamic fields and deleting the key if absent in prevCfg.
|
||||
/// Mirrors <c>copyStreamMetadata</c>.
|
||||
/// </summary>
|
||||
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<string, string>();
|
||||
cfg.Metadata[key] = value;
|
||||
return;
|
||||
}
|
||||
if (cfg.Metadata != null)
|
||||
{
|
||||
cfg.Metadata.Remove(key);
|
||||
if (cfg.Metadata.Count == 0)
|
||||
cfg.Metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Consumer metadata mutations ----
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>setStaticConsumerMetadata</c>.
|
||||
/// </summary>
|
||||
public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
|
||||
{
|
||||
cfg.Metadata ??= new Dictionary<string, string>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow copy of the consumer config with dynamic versioning fields added to a new
|
||||
/// metadata dictionary. Does not mutate <paramref name="cfg"/>.
|
||||
/// Mirrors <c>setDynamicConsumerMetadata</c>.
|
||||
/// </summary>
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow copy of the consumer info with dynamic versioning fields added to the
|
||||
/// config's metadata. Does not mutate <paramref name="info"/>.
|
||||
/// Mirrors <c>setDynamicConsumerInfoMetadata</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the required-level versioning field from <paramref name="prevCfg"/> into
|
||||
/// <paramref name="cfg"/>, removing dynamic fields and deleting the key if absent in prevCfg.
|
||||
/// Mirrors <c>copyConsumerMetadata</c>.
|
||||
/// </summary>
|
||||
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<string, string>();
|
||||
cfg.Metadata[key] = value;
|
||||
return;
|
||||
}
|
||||
if (cfg.Metadata != null)
|
||||
{
|
||||
cfg.Metadata.Remove(key);
|
||||
if (cfg.Metadata.Count == 0)
|
||||
cfg.Metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Copies all scalar/reference properties from <paramref name="src"/> to <paramref name="dst"/>,
|
||||
/// excluding <c>Metadata</c> (which is set separately by the caller).
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
@@ -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%)**
|
||||
|
||||
39
reports/report_6e90eea.md
Normal file
39
reports/report_6e90eea.md
Normal file
@@ -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%)**
|
||||
Reference in New Issue
Block a user