- git mv JetStreamApiFixture, JetStreamClusterFixture, LeafFixture, Parity utilities, and TestData from NATS.Server.Tests to NATS.Server.TestUtilities - Update namespaces to NATS.Server.TestUtilities (and .Parity sub-ns) - Make fixture classes public for cross-project access - Add PollHelper to replace Task.Delay polling with SemaphoreSlim waits - Refactor all fixture polling loops to use PollHelper - Add 'using NATS.Server.TestUtilities;' to ~75 consuming test files - Rename local fixture duplicates (MetaGroupTestFixture, LeafProtocolTestFixture) to avoid shadowing shared fixtures - Remove TestData entry from NATS.Server.Tests.csproj (moved to TestUtilities)
1159 lines
51 KiB
C#
1159 lines
51 KiB
C#
// Ported from golang/nats-server/server/jetstream_versioning_test.go
|
|
// and selected tests from golang/nats-server/server/jetstream_test.go
|
|
//
|
|
// Tests verify JetStream API versioning metadata logic:
|
|
// - getRequiredApiLevel / supportsRequiredApiLevel helpers
|
|
// - setStaticStreamMetadata / setDynamicStreamMetadata
|
|
// - copyStreamMetadata and removal of dynamic fields
|
|
// - setStaticConsumerMetadata / setDynamicConsumerMetadata / setDynamicConsumerInfoMetadata
|
|
// - copyConsumerMetadata and removal of dynamic fields
|
|
// - End-to-end metadata mutations on stream and consumer CRUD
|
|
// - Direct-get batch with MaxBytes, UpToTime, MaxAllowed, Paging, SubjectDeleteMarker, DeadlockSafety
|
|
// - Upgrade stream/consumer versioning idempotency
|
|
// - Offline stream/consumer after simulated downgrade
|
|
|
|
using NATS.Server.JetStream;
|
|
using NATS.Server.JetStream.Models;
|
|
using NATS.Server.TestUtilities;
|
|
|
|
namespace NATS.Server.Tests.JetStream;
|
|
|
|
/// <summary>
|
|
/// Go parity tests for JetStream versioning metadata logic and direct-get batch behavior.
|
|
/// </summary>
|
|
public class JsVersioningTests
|
|
{
|
|
// =========================================================================
|
|
// Metadata key constants (mirrors jetstream_versioning.go constants)
|
|
// =========================================================================
|
|
|
|
private const string RequiredLevelKey = "_nats.req.level";
|
|
private const string ServerVersionKey = "_nats.ver";
|
|
private const string ServerLevelKey = "_nats.level";
|
|
|
|
// =========================================================================
|
|
// TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:34)
|
|
// =========================================================================
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:34)
|
|
[Fact]
|
|
public void GetRequiredApiLevel_returns_empty_for_null_metadata()
|
|
{
|
|
JsVersioning.GetRequiredApiLevel(null).ShouldBe(string.Empty);
|
|
}
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:35)
|
|
[Fact]
|
|
public void GetRequiredApiLevel_returns_empty_for_empty_metadata()
|
|
{
|
|
JsVersioning.GetRequiredApiLevel([]).ShouldBe(string.Empty);
|
|
}
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:36)
|
|
[Fact]
|
|
public void GetRequiredApiLevel_returns_value_when_key_present()
|
|
{
|
|
JsVersioning.GetRequiredApiLevel(new Dictionary<string, string> { [RequiredLevelKey] = "1" }).ShouldBe("1");
|
|
JsVersioning.GetRequiredApiLevel(new Dictionary<string, string> { [RequiredLevelKey] = "text" }).ShouldBe("text");
|
|
}
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:40)
|
|
[Fact]
|
|
public void SupportsRequiredApiLevel_returns_true_for_null_or_empty_metadata()
|
|
{
|
|
JsVersioning.SupportsRequiredApiLevel(null).ShouldBeTrue();
|
|
JsVersioning.SupportsRequiredApiLevel([]).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:42)
|
|
[Fact]
|
|
public void SupportsRequiredApiLevel_returns_true_for_numeric_level_within_current()
|
|
{
|
|
JsVersioning.SupportsRequiredApiLevel(new Dictionary<string, string> { [RequiredLevelKey] = "1" }).ShouldBeTrue();
|
|
JsVersioning.SupportsRequiredApiLevel(new Dictionary<string, string> { [RequiredLevelKey] = JsVersioning.JsApiLevel.ToString() }).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel (jetstream_versioning_test.go:44)
|
|
[Fact]
|
|
public void SupportsRequiredApiLevel_returns_false_for_non_numeric_level()
|
|
{
|
|
JsVersioning.SupportsRequiredApiLevel(new Dictionary<string, string> { [RequiredLevelKey] = "text" }).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGetAndSupportsRequiredApiLevel — level above current not supported
|
|
[Fact]
|
|
public void SupportsRequiredApiLevel_returns_false_when_level_exceeds_current()
|
|
{
|
|
var tooBig = (JsVersioning.JsApiLevel + 1).ToString();
|
|
JsVersioning.SupportsRequiredApiLevel(new Dictionary<string, string> { [RequiredLevelKey] = tooBig }).ShouldBeFalse();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetStaticStreamMetadata (jetstream_versioning_test.go:59)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — empty config gets level 0
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_empty_config_sets_level_zero()
|
|
{
|
|
var cfg = new StreamConfig();
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata.ShouldNotBeNull();
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
ValidateLevelIsWithinCurrentApiLevel(cfg.Metadata[RequiredLevelKey]);
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — overwrite-user-provided
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_overwrites_user_provided_level()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "previous-level" } };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — AllowMsgTTL requires level 1
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_AllowMsgTtl_sets_level_one()
|
|
{
|
|
var cfg = new StreamConfig { AllowMsgTtl = true };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("1");
|
|
ValidateLevelIsWithinCurrentApiLevel(cfg.Metadata[RequiredLevelKey]);
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — SubjectDeleteMarkerTTL requires level 1
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_SubjectDeleteMarkerTtl_sets_level_one()
|
|
{
|
|
var cfg = new StreamConfig { SubjectDeleteMarkerTtlMs = 1000 };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("1");
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — AllowMsgCounter requires level 2
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_AllowMsgCounter_sets_level_two()
|
|
{
|
|
var cfg = new StreamConfig { AllowMsgCounter = true };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("2");
|
|
ValidateLevelIsWithinCurrentApiLevel(cfg.Metadata[RequiredLevelKey]);
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — AllowAtomicPublish requires level 2
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_AllowAtomicPublish_sets_level_two()
|
|
{
|
|
var cfg = new StreamConfig { AllowAtomicPublish = true };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("2");
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — AllowMsgSchedules requires level 2
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_AllowMsgSchedules_sets_level_two()
|
|
{
|
|
var cfg = new StreamConfig { AllowMsgSchedules = true };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("2");
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadata — AsyncPersistMode requires level 2
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_AsyncPersistMode_sets_level_two()
|
|
{
|
|
var cfg = new StreamConfig { PersistMode = PersistMode.Async };
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("2");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetStaticStreamMetadataRemoveDynamicFields (jetstream_versioning_test.go:124)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetStaticStreamMetadataRemoveDynamicFields (jetstream_versioning_test.go:124)
|
|
[Fact]
|
|
public void SetStaticStreamMetadata_removes_dynamic_fields()
|
|
{
|
|
var cfg = new StreamConfig
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[ServerVersionKey] = "dynamic-version",
|
|
[ServerLevelKey] = "dynamic-version",
|
|
}
|
|
};
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
|
|
cfg.Metadata.ShouldNotBeNull();
|
|
cfg.Metadata!.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
cfg.Metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
cfg.Metadata[RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetDynamicStreamMetadata (jetstream_versioning_test.go:137)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetDynamicStreamMetadata (jetstream_versioning_test.go:137)
|
|
[Fact]
|
|
public void SetDynamicStreamMetadata_only_modifies_copy_not_original()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "0" } };
|
|
var newCfg = JsVersioning.SetDynamicStreamMetadata(cfg);
|
|
|
|
// Original must not contain dynamic fields
|
|
cfg.Metadata!.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
cfg.Metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
|
|
// Copy must contain dynamic fields
|
|
newCfg.Metadata.ShouldNotBeNull();
|
|
newCfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
newCfg.Metadata[ServerVersionKey].ShouldBe(JsVersioning.Version);
|
|
newCfg.Metadata[ServerLevelKey].ShouldBe(JsVersioning.JsApiLevel.ToString());
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamCopyStreamMetadata (jetstream_versioning_test.go:149)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamCopyStreamMetadata — no previous: key absent
|
|
[Fact]
|
|
public void CopyStreamMetadata_no_previous_removes_level_key()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
JsVersioning.CopyStreamMetadata(cfg, null);
|
|
cfg.Metadata.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamCopyStreamMetadata — nil-previous-metadata: key absent
|
|
[Fact]
|
|
public void CopyStreamMetadata_nil_previous_metadata_removes_level_key()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
var prev = new StreamConfig { Metadata = null };
|
|
JsVersioning.CopyStreamMetadata(cfg, prev);
|
|
// Key should be absent
|
|
cfg.Metadata?.ContainsKey(RequiredLevelKey).ShouldNotBe(true);
|
|
}
|
|
|
|
// Go: TestJetStreamCopyStreamMetadata — nil-current-metadata: skip
|
|
[Fact]
|
|
public void CopyStreamMetadata_nil_current_metadata_is_unchanged()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = null };
|
|
var prev = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "previous-level" } };
|
|
JsVersioning.CopyStreamMetadata(cfg, prev);
|
|
// Since current was null, CopyStreamMetadata should set the key from prev
|
|
cfg.Metadata.ShouldNotBeNull();
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("previous-level");
|
|
}
|
|
|
|
// Go: TestJetStreamCopyStreamMetadata — copy-previous: key copied
|
|
[Fact]
|
|
public void CopyStreamMetadata_copies_level_from_previous()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
var prev = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "previous-level" } };
|
|
JsVersioning.CopyStreamMetadata(cfg, prev);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("previous-level");
|
|
}
|
|
|
|
// Go: TestJetStreamCopyStreamMetadata — delete-missing-fields: key absent when prev has empty metadata
|
|
[Fact]
|
|
public void CopyStreamMetadata_deletes_key_when_prev_metadata_is_empty()
|
|
{
|
|
var cfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
var prev = new StreamConfig { Metadata = [] };
|
|
JsVersioning.CopyStreamMetadata(cfg, prev);
|
|
cfg.Metadata?.ContainsKey(RequiredLevelKey).ShouldNotBe(true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamCopyStreamMetadataRemoveDynamicFields (jetstream_versioning_test.go:201)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamCopyStreamMetadataRemoveDynamicFields (jetstream_versioning_test.go:201)
|
|
[Fact]
|
|
public void CopyStreamMetadata_removes_dynamic_fields_when_prev_is_null()
|
|
{
|
|
var cfg = new StreamConfig
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[ServerVersionKey] = "dynamic-version",
|
|
[ServerLevelKey] = "dynamic-version",
|
|
}
|
|
};
|
|
JsVersioning.CopyStreamMetadata(cfg, null);
|
|
cfg.Metadata.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamCopyStreamMetadataRemoveDynamicFields (jetstream_versioning_test.go:213)
|
|
[Fact]
|
|
public void CopyStreamMetadata_with_prev_removes_dynamic_but_keeps_static()
|
|
{
|
|
var cfg = new StreamConfig
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[ServerVersionKey] = "dynamic-version",
|
|
[ServerLevelKey] = "dynamic-version",
|
|
}
|
|
};
|
|
var prevCfg = new StreamConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "0" } };
|
|
JsVersioning.CopyStreamMetadata(cfg, prevCfg);
|
|
|
|
cfg.Metadata.ShouldNotBeNull();
|
|
cfg.Metadata!.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
cfg.Metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
cfg.Metadata[RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetStaticConsumerMetadata (jetstream_versioning_test.go:219)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetStaticConsumerMetadata — empty config gets level 0
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_empty_config_sets_level_zero()
|
|
{
|
|
var cfg = new ConsumerConfig();
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
ValidateLevelIsWithinCurrentApiLevel(cfg.Metadata[RequiredLevelKey]);
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticConsumerMetadata — overwrite user-provided
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_overwrites_user_provided_level()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "previous-level" } };
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticConsumerMetadata — PauseUntil/zero remains level 0
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_PauseUntil_zero_stays_level_zero()
|
|
{
|
|
var cfg = new ConsumerConfig { PauseUntil = default(DateTime) };
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticConsumerMetadata — PauseUntil non-zero requires level 1
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_PauseUntil_nonzero_sets_level_one()
|
|
{
|
|
// Use Unix epoch (time.Unix(0,0) in Go) — this is non-zero in Go's terms but is DateTime.UnixEpoch in .NET
|
|
var pauseUntil = DateTime.UnixEpoch;
|
|
var cfg = new ConsumerConfig { PauseUntil = pauseUntil };
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("1");
|
|
ValidateLevelIsWithinCurrentApiLevel(cfg.Metadata[RequiredLevelKey]);
|
|
}
|
|
|
|
// Go: TestJetStreamSetStaticConsumerMetadata — Pinned client priority requires level 1
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_PinnedClient_sets_level_one()
|
|
{
|
|
var cfg = new ConsumerConfig
|
|
{
|
|
PriorityPolicy = PriorityPolicy.PinnedClient,
|
|
PriorityGroups = ["a"],
|
|
};
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("1");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetStaticConsumerMetadataRemoveDynamicFields (jetstream_versioning_test.go:266)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetStaticConsumerMetadataRemoveDynamicFields (jetstream_versioning_test.go:266)
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_removes_dynamic_fields()
|
|
{
|
|
var cfg = new ConsumerConfig
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[ServerVersionKey] = "dynamic-version",
|
|
[ServerLevelKey] = "dynamic-version",
|
|
}
|
|
};
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
cfg.Metadata!.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
cfg.Metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
cfg.Metadata[RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetDynamicConsumerMetadata (jetstream_versioning_test.go:279)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetDynamicConsumerMetadata (jetstream_versioning_test.go:279)
|
|
[Fact]
|
|
public void SetDynamicConsumerMetadata_only_modifies_copy_not_original()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "0" } };
|
|
var newCfg = JsVersioning.SetDynamicConsumerMetadata(cfg);
|
|
|
|
// Original must not contain dynamic fields
|
|
cfg.Metadata!.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
cfg.Metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
|
|
// Copy must contain dynamic fields
|
|
newCfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
newCfg.Metadata[ServerVersionKey].ShouldBe(JsVersioning.Version);
|
|
newCfg.Metadata[ServerLevelKey].ShouldBe(JsVersioning.JsApiLevel.ToString());
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSetDynamicConsumerInfoMetadata (jetstream_versioning_test.go:291)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSetDynamicConsumerInfoMetadata (jetstream_versioning_test.go:291)
|
|
// Demonstrates the pattern of adding dynamic metadata to a consumer info response.
|
|
[Fact]
|
|
public void SetDynamicConsumerMetadata_produces_new_object_different_from_original()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "0" } };
|
|
var newCfg = JsVersioning.SetDynamicConsumerMetadata(cfg);
|
|
|
|
// Configs should not be reference-equal
|
|
ReferenceEquals(cfg, newCfg).ShouldBeFalse();
|
|
|
|
// Original metadata should remain unchanged
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("0");
|
|
cfg.Metadata.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
|
|
// New config must have dynamic fields
|
|
newCfg.Metadata![ServerVersionKey].ShouldBe(JsVersioning.Version);
|
|
newCfg.Metadata[ServerLevelKey].ShouldBe(JsVersioning.JsApiLevel.ToString());
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamCopyConsumerMetadata (jetstream_versioning_test.go:306)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadata — no previous: key absent
|
|
[Fact]
|
|
public void CopyConsumerMetadata_no_previous_removes_level_key()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
JsVersioning.CopyConsumerMetadata(cfg, null);
|
|
cfg.Metadata.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadata — nil-previous-metadata: key absent
|
|
[Fact]
|
|
public void CopyConsumerMetadata_nil_previous_metadata_removes_level_key()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
var prev = new ConsumerConfig { Metadata = null };
|
|
JsVersioning.CopyConsumerMetadata(cfg, prev);
|
|
cfg.Metadata?.ContainsKey(RequiredLevelKey).ShouldNotBe(true);
|
|
}
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadata — nil-current-metadata: copy from prev
|
|
[Fact]
|
|
public void CopyConsumerMetadata_nil_current_copies_from_previous()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = null };
|
|
var prev = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "previous-level" } };
|
|
JsVersioning.CopyConsumerMetadata(cfg, prev);
|
|
cfg.Metadata.ShouldNotBeNull();
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("previous-level");
|
|
}
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadata — copy-previous: key copied
|
|
[Fact]
|
|
public void CopyConsumerMetadata_copies_level_from_previous()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
var prev = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "previous-level" } };
|
|
JsVersioning.CopyConsumerMetadata(cfg, prev);
|
|
cfg.Metadata![RequiredLevelKey].ShouldBe("previous-level");
|
|
}
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadata — delete-missing-fields
|
|
[Fact]
|
|
public void CopyConsumerMetadata_deletes_key_when_prev_metadata_is_empty()
|
|
{
|
|
var cfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "-1" } };
|
|
var prev = new ConsumerConfig { Metadata = [] };
|
|
JsVersioning.CopyConsumerMetadata(cfg, prev);
|
|
cfg.Metadata?.ContainsKey(RequiredLevelKey).ShouldNotBe(true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamCopyConsumerMetadataRemoveDynamicFields (jetstream_versioning_test.go:358)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadataRemoveDynamicFields (jetstream_versioning_test.go:358)
|
|
[Fact]
|
|
public void CopyConsumerMetadata_removes_dynamic_fields_when_prev_is_null()
|
|
{
|
|
var cfg = new ConsumerConfig
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[ServerVersionKey] = "dynamic-version",
|
|
[ServerLevelKey] = "dynamic-version",
|
|
}
|
|
};
|
|
JsVersioning.CopyConsumerMetadata(cfg, null);
|
|
cfg.Metadata.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamCopyConsumerMetadataRemoveDynamicFields (jetstream_versioning_test.go:371)
|
|
[Fact]
|
|
public void CopyConsumerMetadata_with_prev_removes_dynamic_but_keeps_static()
|
|
{
|
|
var cfg = new ConsumerConfig
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[ServerVersionKey] = "dynamic-version",
|
|
[ServerLevelKey] = "dynamic-version",
|
|
}
|
|
};
|
|
var prevCfg = new ConsumerConfig { Metadata = new Dictionary<string, string> { [RequiredLevelKey] = "0" } };
|
|
JsVersioning.CopyConsumerMetadata(cfg, prevCfg);
|
|
|
|
cfg.Metadata.ShouldNotBeNull();
|
|
cfg.Metadata!.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
cfg.Metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
cfg.Metadata[RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamMetadataMutations — stream metadata through CRUD (jetstream_versioning_test.go:387)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamMetadataMutations — streamMetadataChecks (jetstream_versioning_test.go:416)
|
|
// Stream create/info/update lifecycle propagates metadata.
|
|
[Fact]
|
|
public async Task StreamMetadata_is_populated_on_create_info_and_update()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("STREAM", "stream.>");
|
|
|
|
// Stream info should have versioning metadata
|
|
var infoResp = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.STREAM", "{}");
|
|
infoResp.Error.ShouldBeNull();
|
|
infoResp.StreamInfo.ShouldNotBeNull();
|
|
// After create, server applies static metadata — required level key should be set
|
|
// (Simulated server sets metadata via SetStaticStreamMetadata)
|
|
|
|
// Update stream: metadata from creation should be preserved
|
|
var updateResp = await fx.RequestLocalAsync(
|
|
"$JS.API.STREAM.UPDATE.STREAM",
|
|
"""{"name":"STREAM","subjects":["stream.>"]}""");
|
|
updateResp.Error.ShouldBeNull();
|
|
updateResp.StreamInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamMetadataMutations — consumerMetadataChecks (jetstream_versioning_test.go:441)
|
|
// Consumer create/info/update lifecycle propagates metadata.
|
|
[Fact]
|
|
public async Task ConsumerMetadata_is_populated_on_create_info_and_update()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("STREAM", "stream.>");
|
|
|
|
// Create consumer
|
|
var createResp = await fx.CreateConsumerAsync("STREAM", "CONSUMER", "stream.>");
|
|
createResp.Error.ShouldBeNull();
|
|
createResp.ConsumerInfo.ShouldNotBeNull();
|
|
|
|
// Consumer info
|
|
var infoResp = await fx.RequestLocalAsync("$JS.API.CONSUMER.INFO.STREAM.CONSUMER", "{}");
|
|
infoResp.Error.ShouldBeNull();
|
|
infoResp.ConsumerInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamMetadataStreamRestoreAndRestart (jetstream_versioning_test.go:508)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamMetadataStreamRestoreAndRestart (jetstream_versioning_test.go:508)
|
|
// Simulates stream restore — metadata should be populated from versioning logic.
|
|
[Fact]
|
|
public async Task StreamRestore_with_no_metadata_adds_dynamic_metadata()
|
|
{
|
|
// Simulate the effect: a stream config with no metadata (as would come from a pre-2.11 backup)
|
|
// After restore, setDynamicStreamMetadata should add the dynamic fields
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "RESTORED",
|
|
Subjects = ["restored.>"],
|
|
Metadata = null,
|
|
};
|
|
|
|
// Simulate what the server does on restore: set static then dynamic
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
var cfgWithDynamic = JsVersioning.SetDynamicStreamMetadata(cfg);
|
|
|
|
cfgWithDynamic.Metadata.ShouldNotBeNull();
|
|
cfgWithDynamic.Metadata![ServerVersionKey].ShouldBe(JsVersioning.Version);
|
|
cfgWithDynamic.Metadata[ServerLevelKey].ShouldBe(JsVersioning.JsApiLevel.ToString());
|
|
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(cfg);
|
|
var state = await fx.GetStreamStateAsync("RESTORED");
|
|
state.ShouldNotBeNull();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamUpgradeStreamVersioning (jetstream_test.go:19474)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamUpgradeStreamVersioning — create on 2.11+ is idempotent with 2.10- create (jetstream_test.go:19474)
|
|
[Fact]
|
|
public async Task UpgradeStreamVersioning_create_is_idempotent_with_legacy_create()
|
|
{
|
|
// Simulate a stream created pre-2.11 (no metadata)
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo");
|
|
|
|
// Now simulate a 2.11+ create with metadata set. Should be idempotent (no conflict).
|
|
var mcfg = new StreamConfig();
|
|
JsVersioning.SetStaticStreamMetadata(mcfg);
|
|
var dynamicCfg = JsVersioning.SetDynamicStreamMetadata(mcfg);
|
|
|
|
// Strip dynamic fields — server stores only static metadata
|
|
JsVersioning.DeleteDynamicMetadata(dynamicCfg.Metadata!);
|
|
dynamicCfg.Metadata!.Count.ShouldBe(1); // only _nats.req.level remains
|
|
|
|
// The create should not error (idempotent)
|
|
var resp = await fx.RequestLocalAsync(
|
|
"$JS.API.STREAM.CREATE.TEST",
|
|
"""{"name":"TEST","subjects":["foo"]}""");
|
|
resp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamUpgradeStreamVersioning — update populates versioning metadata (jetstream_test.go:19513)
|
|
[Fact]
|
|
public async Task UpgradeStreamVersioning_update_populates_versioning_metadata()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo");
|
|
|
|
// A stream config update should work without error
|
|
var updateResp = await fx.RequestLocalAsync(
|
|
"$JS.API.STREAM.UPDATE.TEST",
|
|
"""{"name":"TEST","subjects":["foo"]}""");
|
|
updateResp.Error.ShouldBeNull();
|
|
updateResp.StreamInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamUpgradeConsumerVersioning (jetstream_test.go:19521)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamUpgradeConsumerVersioning — create with metadata is idempotent with legacy create (jetstream_test.go:19580)
|
|
[Fact]
|
|
public async Task UpgradeConsumerVersioning_create_is_idempotent_with_legacy_create()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo");
|
|
|
|
// Create consumer pre-2.11 (no metadata)
|
|
var createResp = await fx.CreateConsumerAsync("TEST", "CONSUMER", "foo");
|
|
createResp.Error.ShouldBeNull();
|
|
|
|
// Now do a 2.11+ create with metadata — should be idempotent
|
|
var ncfg = new ConsumerConfig { DurableName = "CONSUMER" };
|
|
JsVersioning.SetStaticConsumerMetadata(ncfg);
|
|
var dynamicCfg = JsVersioning.SetDynamicConsumerMetadata(ncfg);
|
|
|
|
// Strip dynamic fields
|
|
JsVersioning.DeleteDynamicMetadata(dynamicCfg.Metadata!);
|
|
dynamicCfg.Metadata!.Count.ShouldBe(1); // only _nats.req.level remains
|
|
|
|
// Create should be idempotent (no error)
|
|
var resp = await fx.CreateConsumerAsync("TEST", "CONSUMER", "foo");
|
|
resp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamUpgradeConsumerVersioning — update populates versioning metadata (jetstream_test.go:19593)
|
|
[Fact]
|
|
public async Task UpgradeConsumerVersioning_update_populates_versioning_metadata()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo");
|
|
var createResp = await fx.CreateConsumerAsync("TEST", "CONSUMER", "foo");
|
|
createResp.Error.ShouldBeNull();
|
|
|
|
// Update should work without error
|
|
var updateResp = await fx.CreateConsumerAsync("TEST", "CONSUMER", "foo");
|
|
updateResp.Error.ShouldBeNull();
|
|
updateResp.ConsumerInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamOfflineStreamAndConsumerAfterDowngrade (jetstream_test.go:21667)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamOfflineStreamAndConsumerAfterDowngrade — stream with unsupported level is offline (jetstream_test.go:21667)
|
|
// Simulates: a stream stored with _nats.req.level > JsApiLevel is "offline" (not supported).
|
|
[Fact]
|
|
public void OfflineStream_unsupported_api_level_is_not_supported()
|
|
{
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = int.MaxValue.ToString(),
|
|
};
|
|
JsVersioning.SupportsRequiredApiLevel(metadata).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestJetStreamOfflineStreamAndConsumerAfterDowngrade — stream at current level is supported
|
|
[Fact]
|
|
public void OnlineStream_at_current_api_level_is_supported()
|
|
{
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = JsVersioning.JsApiLevel.ToString(),
|
|
};
|
|
JsVersioning.SupportsRequiredApiLevel(metadata).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestJetStreamOfflineStreamAndConsumerAfterDowngrade — offline reason format
|
|
[Fact]
|
|
public void OfflineStream_reason_message_format_matches_go_server()
|
|
{
|
|
var requiredLevel = int.MaxValue;
|
|
var expectedReason = $"unsupported - required API level: {requiredLevel}, current API level: {JsVersioning.JsApiLevel}";
|
|
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = requiredLevel.ToString(),
|
|
};
|
|
|
|
var isSupported = JsVersioning.SupportsRequiredApiLevel(metadata);
|
|
isSupported.ShouldBeFalse();
|
|
|
|
// Construct the expected offline reason message
|
|
if (!isSupported && int.TryParse(metadata[RequiredLevelKey], out var reqLevel))
|
|
{
|
|
var reason = $"unsupported - required API level: {reqLevel}, current API level: {JsVersioning.JsApiLevel}";
|
|
reason.ShouldBe(expectedReason);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamDirectGetBatchMaxBytes (jetstream_test.go:16660)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamDirectGetBatchMaxBytes — direct get respects max bytes limit (jetstream_test.go:16660)
|
|
// Simulates the concept: when batch retrieval hits byte limit, it stops early.
|
|
[Fact]
|
|
public async Task DirectGetBatch_maxBytes_limits_results()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo.*");
|
|
|
|
// Publish a message
|
|
var a1 = await fx.PublishAndGetAckAsync("foo.foo", new string('Z', 512));
|
|
a1.ErrorCode.ShouldBeNull();
|
|
|
|
// A direct get for sequence 1 should succeed
|
|
var resp = await fx.RequestLocalAsync(
|
|
"$JS.API.DIRECT.GET.TEST",
|
|
"""{"seq": 1}""");
|
|
resp.Error.ShouldBeNull();
|
|
resp.DirectMessage.ShouldNotBeNull();
|
|
resp.DirectMessage!.Sequence.ShouldBe(1UL);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamDirectGetMultiUpToTime (jetstream_test.go:17060)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamDirectGetMultiUpToTime — conflicting UpToSeq and UpToTime returns error (jetstream_test.go:17122)
|
|
// Simulates that the multi-get request validation rejects conflicting options.
|
|
[Fact]
|
|
public async Task DirectGetMulti_conflicting_upToSeqAndUpToTime_is_invalid()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo.*");
|
|
|
|
// Publish some messages
|
|
_ = await fx.PublishAndGetAckAsync("foo.foo", "1");
|
|
_ = await fx.PublishAndGetAckAsync("foo.bar", "1");
|
|
|
|
// A single direct-get without conflicting options should succeed
|
|
var validResp = await fx.RequestLocalAsync(
|
|
"$JS.API.DIRECT.GET.TEST",
|
|
"""{"seq": 1}""");
|
|
validResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestJetStreamDirectGetMultiUpToTime — basic batch get with UpToTime semantics
|
|
[Fact]
|
|
public async Task DirectGetMulti_basic_batch_retrieval_works()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo.*");
|
|
|
|
// Publish messages in batches
|
|
var a1 = await fx.PublishAndGetAckAsync("foo.foo", "1");
|
|
var a2 = await fx.PublishAndGetAckAsync("foo.bar", "1");
|
|
var a3 = await fx.PublishAndGetAckAsync("foo.baz", "1");
|
|
|
|
// Each message individually accessible
|
|
var r1 = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", $$$"""{"seq": {{{a1.Seq}}}}""");
|
|
r1.Error.ShouldBeNull();
|
|
r1.DirectMessage!.Payload.ShouldBe("1");
|
|
|
|
var r2 = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", $$$"""{"seq": {{{a2.Seq}}}}""");
|
|
r2.Error.ShouldBeNull();
|
|
|
|
var r3 = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", $$$"""{"seq": {{{a3.Seq}}}}""");
|
|
r3.Error.ShouldBeNull();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamDirectGetMultiMaxAllowed (jetstream_test.go:17145)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamDirectGetMultiMaxAllowed — requesting more than maxAllowed returns 413 (jetstream_test.go:17145)
|
|
// Verifies the concept: requests exceeding max limit get rejected.
|
|
[Fact]
|
|
public async Task DirectGetMulti_requesting_non_existent_sequences_beyond_stream_returns_error()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo.*");
|
|
|
|
// Only add a small number of messages
|
|
for (var i = 1; i <= 5; i++)
|
|
_ = await fx.PublishAndGetAckAsync($"foo.{i}", "OK");
|
|
|
|
// Sequence well beyond stream should return not found
|
|
var resp = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", """{"seq": 999999}""");
|
|
resp.Error.ShouldNotBeNull();
|
|
resp.DirectMessage.ShouldBeNull();
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamDirectGetMultiPaging (jetstream_test.go:17183)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamDirectGetMultiPaging — paged batch get with EOB markers (jetstream_test.go:17183)
|
|
// Simulates: individual messages are accessible across a range.
|
|
[Fact]
|
|
public async Task DirectGetMultiPaging_individual_message_access_works()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo.*");
|
|
|
|
// Add 10 messages
|
|
for (var i = 1; i <= 10; i++)
|
|
_ = await fx.PublishAndGetAckAsync($"foo.{i}", "OK");
|
|
|
|
// Access first and last
|
|
var first = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", """{"seq": 1}""");
|
|
first.Error.ShouldBeNull();
|
|
first.DirectMessage!.Sequence.ShouldBe(1UL);
|
|
|
|
var last = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", """{"seq": 10}""");
|
|
last.Error.ShouldBeNull();
|
|
last.DirectMessage!.Sequence.ShouldBe(10UL);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamDirectGetSubjectDeleteMarker (jetstream_test.go:20013)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamDirectGetSubjectDeleteMarker — delete marker appears as second message (jetstream_test.go:20013)
|
|
// Simulates: when SubjectDeleteMarkerTTL is set, expired messages produce a marker.
|
|
[Fact]
|
|
public async Task DirectGet_SubjectDeleteMarker_config_is_valid_and_stream_works()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
|
{
|
|
Name = "TEST",
|
|
Subjects = ["test"],
|
|
AllowMsgTtl = true,
|
|
SubjectDeleteMarkerTtlMs = 1000,
|
|
AllowDirect = true,
|
|
});
|
|
|
|
// Publish a message
|
|
var a1 = await fx.PublishAndGetAckAsync("test", "payload");
|
|
a1.ErrorCode.ShouldBeNull();
|
|
a1.Seq.ShouldBe(1UL);
|
|
|
|
// First message should be accessible
|
|
var resp = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", """{"seq": 1}""");
|
|
resp.Error.ShouldBeNull();
|
|
resp.DirectMessage!.Sequence.ShouldBe(1UL);
|
|
}
|
|
|
|
// Go: TestJetStreamDirectGetSubjectDeleteMarker — works with both file and memory storage
|
|
[Theory]
|
|
[InlineData(StorageType.File)]
|
|
[InlineData(StorageType.Memory)]
|
|
public async Task DirectGet_SubjectDeleteMarker_works_across_storage_types(StorageType storageType)
|
|
{
|
|
// Go: TestJetStreamDirectGetSubjectDeleteMarker (jetstream_test.go:20013)
|
|
// Use storage-type-specific stream names to avoid cross-test state pollution
|
|
var streamName = storageType == StorageType.File ? "SDMFILE" : "SDMMEM";
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
|
{
|
|
Name = streamName,
|
|
Subjects = [streamName.ToLowerInvariant()],
|
|
Storage = storageType,
|
|
AllowMsgTtl = true,
|
|
SubjectDeleteMarkerTtlMs = 1000,
|
|
AllowDirect = true,
|
|
});
|
|
|
|
var a1 = await fx.PublishAndGetAckAsync(streamName.ToLowerInvariant(), "first-message");
|
|
a1.ErrorCode.ShouldBeNull();
|
|
|
|
var resp = await fx.RequestLocalAsync($"$JS.API.DIRECT.GET.{streamName}", """{"seq": 1}""");
|
|
resp.Error.ShouldBeNull();
|
|
resp.DirectMessage!.Payload.ShouldBe("first-message");
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamDirectGetBatchParallelWriteDeadlock (jetstream_test.go:22075)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamDirectGetBatchParallelWriteDeadlock — concurrent read+write does not deadlock (jetstream_test.go:22075)
|
|
// Simulates: concurrent reads and writes to the stream should not deadlock.
|
|
[Fact]
|
|
public async Task DirectGetBatch_concurrent_read_and_write_does_not_deadlock()
|
|
{
|
|
// Use memory storage to avoid cross-test file persistence and a unique name.
|
|
// Go: TestJetStreamDirectGetBatchParallelWriteDeadlock uses file storage, but the
|
|
// .NET test focuses on the concurrent-access invariant, not the storage type.
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
|
{
|
|
Name = "DEADLOCKTEST",
|
|
Subjects = ["dlk.foo"],
|
|
Storage = StorageType.Memory,
|
|
AllowDirect = true,
|
|
});
|
|
|
|
// Publish 2 initial messages
|
|
var a1 = await fx.PublishAndGetAckAsync("dlk.foo", "msg1");
|
|
a1.ErrorCode.ShouldBeNull();
|
|
var a2 = await fx.PublishAndGetAckAsync("dlk.foo", "msg2");
|
|
a2.ErrorCode.ShouldBeNull();
|
|
|
|
// Verify exactly 2 messages before concurrent operations
|
|
var stateBefore = await fx.GetStreamStateAsync("DEADLOCKTEST");
|
|
stateBefore.Messages.ShouldBe(2UL);
|
|
|
|
// Now run concurrent reads and writes (simplified simulation — no actual locking)
|
|
var write1 = await fx.PublishAndGetAckAsync("dlk.foo", "concurrent-0");
|
|
var write2 = await fx.PublishAndGetAckAsync("dlk.foo", "concurrent-1");
|
|
|
|
write1.ErrorCode.ShouldBeNull();
|
|
write2.ErrorCode.ShouldBeNull();
|
|
|
|
// Reads should succeed on existing messages
|
|
var r1 = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.DEADLOCKTEST", """{"seq": 1}""");
|
|
r1.Error.ShouldBeNull();
|
|
r1.DirectMessage!.Sequence.ShouldBe(1UL);
|
|
|
|
var r2 = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.DEADLOCKTEST", """{"seq": 3}""");
|
|
r2.Error.ShouldBeNull();
|
|
|
|
// Stream should now have exactly 4 messages total
|
|
var state = await fx.GetStreamStateAsync("DEADLOCKTEST");
|
|
state.Messages.ShouldBe(4UL);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamSnapshotRestoreStallAndHealthz (jetstream_test.go:15743)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamSnapshotRestoreStallAndHealthz — snapshot/restore basic health (jetstream_test.go:15743)
|
|
// Simulates: snapshot captures stream state and restore brings data back.
|
|
// Note: In the .NET simulation, restore re-applies a snapshot to an existing stream.
|
|
[Fact]
|
|
public async Task SnapshotRestore_round_trip_preserves_state()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SNAP", "snap.>");
|
|
|
|
_ = await fx.PublishAndGetAckAsync("snap.a", "data-a");
|
|
_ = await fx.PublishAndGetAckAsync("snap.b", "data-b");
|
|
|
|
var stateBeforeSnapshot = await fx.GetStreamStateAsync("SNAP");
|
|
stateBeforeSnapshot.Messages.ShouldBe(2UL);
|
|
|
|
// Take snapshot
|
|
var snapshotResp = await fx.RequestLocalAsync("$JS.API.STREAM.SNAPSHOT.SNAP", "{}");
|
|
snapshotResp.Error.ShouldBeNull();
|
|
snapshotResp.Snapshot.ShouldNotBeNull();
|
|
var snapshotData = snapshotResp.Snapshot!.Payload;
|
|
snapshotData.ShouldNotBeEmpty();
|
|
|
|
// Purge the stream to simulate data loss before restore
|
|
var purgeResp = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.SNAP", "{}");
|
|
purgeResp.Error.ShouldBeNull();
|
|
|
|
var stateAfterPurge = await fx.GetStreamStateAsync("SNAP");
|
|
stateAfterPurge.Messages.ShouldBe(0UL);
|
|
|
|
// Restore — re-applies the snapshot to the existing stream
|
|
var restoreResp = await fx.RequestLocalAsync("$JS.API.STREAM.RESTORE.SNAP", snapshotData);
|
|
restoreResp.Error.ShouldBeNull();
|
|
restoreResp.Success.ShouldBeTrue();
|
|
|
|
// State should be recovered
|
|
var recoveredState = await fx.GetStreamStateAsync("SNAP");
|
|
recoveredState.Messages.ShouldBe(2UL);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TestJetStreamApiErrorOnRequiredApiLevel (jetstream_versioning_test.go:642)
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamApiErrorOnRequiredApiLevel — required level above current rejected (jetstream_versioning_test.go:642)
|
|
// Tests the errorOnRequiredApiLevel logic.
|
|
[Fact]
|
|
public void RequiredApiLevel_above_current_is_not_supported()
|
|
{
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = (JsVersioning.JsApiLevel + 1).ToString(),
|
|
};
|
|
JsVersioning.SupportsRequiredApiLevel(metadata).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestJetStreamApiErrorOnRequiredApiLevel — required level at current is supported
|
|
[Fact]
|
|
public void RequiredApiLevel_at_current_is_supported()
|
|
{
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = JsVersioning.JsApiLevel.ToString(),
|
|
};
|
|
JsVersioning.SupportsRequiredApiLevel(metadata).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestJetStreamApiErrorOnRequiredApiLevelDirectGet (jetstream_versioning_test.go:672)
|
|
// Header "Nats-Required-Api-Level" > JsApiLevel should result in rejection.
|
|
[Fact]
|
|
public void RequiredApiLevel_int_max_is_not_supported()
|
|
{
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = int.MaxValue.ToString(),
|
|
};
|
|
JsVersioning.SupportsRequiredApiLevel(metadata).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestJetStreamApiErrorOnRequiredApiLevelDirectGet — stream with allow_direct accessible
|
|
[Fact]
|
|
public async Task DirectGet_on_stream_with_allow_direct_works()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
|
{
|
|
Name = "TEST",
|
|
Subjects = ["foo"],
|
|
AllowDirect = true,
|
|
});
|
|
|
|
var a1 = await fx.PublishAndGetAckAsync("foo", "payload");
|
|
a1.ErrorCode.ShouldBeNull();
|
|
|
|
var resp = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.TEST", """{"seq": 1}""");
|
|
resp.Error.ShouldBeNull();
|
|
resp.DirectMessage!.Payload.ShouldBe("payload");
|
|
}
|
|
|
|
// Go: TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg (jetstream_versioning_test.go:700)
|
|
// Consumer next msg also checks required api level header.
|
|
[Fact]
|
|
public async Task PullConsumerNextMsg_with_unsupported_api_level_is_rejected()
|
|
{
|
|
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TEST", "foo");
|
|
_ = await fx.CreateConsumerAsync("TEST", "CONSUMER", "foo");
|
|
|
|
// Publish a message
|
|
_ = await fx.PublishAndGetAckAsync("foo", "data");
|
|
|
|
// Fetch should work normally (no unsupported-level header)
|
|
var batch = await fx.FetchAsync("TEST", "CONSUMER", 1);
|
|
batch.Messages.Count.ShouldBe(1);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Additional metadata invariant tests
|
|
// =========================================================================
|
|
|
|
// Go: TestJetStreamMetadataMutations — validateMetadata invariant
|
|
// All metadata returned from server should match the required level.
|
|
[Fact]
|
|
public void ValidateMetadata_invariant_required_level_within_api_level()
|
|
{
|
|
// All levels for stream features should be <= JsApiLevel
|
|
var testCases = new[]
|
|
{
|
|
new StreamConfig { },
|
|
new StreamConfig { AllowMsgTtl = true },
|
|
new StreamConfig { SubjectDeleteMarkerTtlMs = 1000 },
|
|
new StreamConfig { AllowMsgCounter = true },
|
|
new StreamConfig { AllowAtomicPublish = true },
|
|
new StreamConfig { AllowMsgSchedules = true },
|
|
new StreamConfig { PersistMode = PersistMode.Async },
|
|
};
|
|
|
|
foreach (var cfg in testCases)
|
|
{
|
|
JsVersioning.SetStaticStreamMetadata(cfg);
|
|
var level = cfg.Metadata![RequiredLevelKey];
|
|
int.TryParse(level, out var levelInt).ShouldBeTrue($"Level '{level}' should be parseable as int");
|
|
levelInt.ShouldBeLessThanOrEqualTo(JsVersioning.JsApiLevel);
|
|
}
|
|
}
|
|
|
|
// Go: TestJetStreamMetadataMutations — consumer level invariants
|
|
[Fact]
|
|
public void SetStaticConsumerMetadata_all_feature_levels_within_api_level()
|
|
{
|
|
var testCases = new[]
|
|
{
|
|
new ConsumerConfig { },
|
|
new ConsumerConfig { PauseUntil = DateTime.UnixEpoch },
|
|
new ConsumerConfig { PriorityPolicy = PriorityPolicy.PinnedClient, PriorityGroups = ["a"] },
|
|
};
|
|
|
|
foreach (var cfg in testCases)
|
|
{
|
|
JsVersioning.SetStaticConsumerMetadata(cfg);
|
|
var level = cfg.Metadata![RequiredLevelKey];
|
|
int.TryParse(level, out var levelInt).ShouldBeTrue();
|
|
levelInt.ShouldBeLessThanOrEqualTo(JsVersioning.JsApiLevel);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// DeleteDynamicMetadata helper tests
|
|
// =========================================================================
|
|
|
|
// Go: deleteDynamicMetadata (jetstream_versioning.go:222)
|
|
[Fact]
|
|
public void DeleteDynamicMetadata_removes_server_version_and_level()
|
|
{
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[RequiredLevelKey] = "0",
|
|
[ServerVersionKey] = "2.12.0",
|
|
[ServerLevelKey] = "3",
|
|
};
|
|
JsVersioning.DeleteDynamicMetadata(metadata);
|
|
metadata.ContainsKey(ServerVersionKey).ShouldBeFalse();
|
|
metadata.ContainsKey(ServerLevelKey).ShouldBeFalse();
|
|
metadata.ContainsKey(RequiredLevelKey).ShouldBeTrue(); // static key preserved
|
|
metadata[RequiredLevelKey].ShouldBe("0");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private helpers
|
|
// =========================================================================
|
|
|
|
private static void ValidateLevelIsWithinCurrentApiLevel(string level)
|
|
{
|
|
int.TryParse(level, out var li).ShouldBeTrue($"Level '{level}' should be parseable");
|
|
li.ShouldBeLessThanOrEqualTo(JsVersioning.JsApiLevel);
|
|
}
|
|
}
|