feat: complete remaining jetstream parity implementation plan
This commit is contained in:
17
tests/NATS.Server.Tests/JetStreamAccountInfoApiTests.cs
Normal file
17
tests/NATS.Server.Tests/JetStreamAccountInfoApiTests.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamAccountInfoApiTests
|
||||
{
|
||||
[Fact]
|
||||
public void Account_info_returns_jetstream_limits_and_usage_shape()
|
||||
{
|
||||
var router = new JetStreamApiRouter(new StreamManager(), new ConsumerManager());
|
||||
var response = router.Route("$JS.API.INFO", "{}"u8);
|
||||
|
||||
response.AccountInfo.ShouldNotBeNull();
|
||||
response.Error.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,13 @@ internal sealed class JetStreamApiFixture : IAsyncDisposable
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithAckAllConsumerAsync()
|
||||
{
|
||||
var fixture = await StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fixture.CreateConsumerAsync("ORDERS", "ACKALL", "orders.created", ackPolicy: AckPolicy.All);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithMirrorSetupAsync()
|
||||
{
|
||||
var fixture = await StartWithStreamAsync("ORDERS", "orders.*");
|
||||
@@ -111,6 +118,16 @@ internal sealed class JetStreamApiFixture : IAsyncDisposable
|
||||
return PublishAndGetAckAsync(subject, payload);
|
||||
}
|
||||
|
||||
public Task<PubAck> PublishWithExpectedLastSeqAsync(string subject, string payload, ulong expectedLastSeq)
|
||||
{
|
||||
if (_publisher.TryCaptureWithOptions(subject, Encoding.UTF8.GetBytes(payload), new PublishOptions { ExpectedLastSeq = expectedLastSeq }, out var ack))
|
||||
{
|
||||
return Task.FromResult(ack);
|
||||
}
|
||||
|
||||
return Task.FromResult(new PubAck { ErrorCode = 404 });
|
||||
}
|
||||
|
||||
public Task<JetStreamApiResponse> RequestLocalAsync(string subject, string payload)
|
||||
{
|
||||
return Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
|
||||
@@ -148,6 +165,15 @@ internal sealed class JetStreamApiFixture : IAsyncDisposable
|
||||
return _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
|
||||
}
|
||||
|
||||
public Task<PullFetchBatch> FetchWithNoWaitAsync(string stream, string durableName, int batch)
|
||||
{
|
||||
return _consumerManager.FetchAsync(stream, durableName, new PullFetchRequest
|
||||
{
|
||||
Batch = batch,
|
||||
NoWait = true,
|
||||
}, _streamManager, default).AsTask();
|
||||
}
|
||||
|
||||
public async Task<PullFetchBatch> FetchAfterDelayAsync(string stream, string durableName, int delayMs, int batch)
|
||||
{
|
||||
await Task.Delay(delayMs);
|
||||
@@ -174,5 +200,22 @@ internal sealed class JetStreamApiFixture : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PublishManyAsync(string subject, IReadOnlyList<string> payloads)
|
||||
{
|
||||
foreach (var payload in payloads)
|
||||
_ = await PublishAndGetAckAsync(subject, payload);
|
||||
}
|
||||
|
||||
public Task AckAllAsync(string stream, string durableName, ulong sequence)
|
||||
{
|
||||
_consumerManager.AckAll(stream, durableName, sequence);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> GetPendingCountAsync(string stream, string durableName)
|
||||
{
|
||||
return Task.FromResult(_consumerManager.GetPendingCount(stream, durableName));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
55
tests/NATS.Server.Tests/JetStreamApiInventoryTests.cs
Normal file
55
tests/NATS.Server.Tests/JetStreamApiInventoryTests.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamApiInventoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Go_inventory_contains_api_subjects_not_yet_mapped_in_dotnet()
|
||||
{
|
||||
var inventory = JetStreamApiInventory.LoadFromGoConstants();
|
||||
inventory.GoSubjects.ShouldContain("$JS.API.STREAM.UPDATE.*");
|
||||
inventory.GoSubjects.ShouldContain("$JS.API.CONSUMER.MSG.NEXT.*.*");
|
||||
inventory.GoSubjects.Count.ShouldBeGreaterThan(20);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class JetStreamApiInventory
|
||||
{
|
||||
public IReadOnlyList<string> GoSubjects { get; }
|
||||
|
||||
private JetStreamApiInventory(IReadOnlyList<string> goSubjects)
|
||||
{
|
||||
GoSubjects = goSubjects;
|
||||
}
|
||||
|
||||
public static JetStreamApiInventory LoadFromGoConstants()
|
||||
{
|
||||
var script = Path.Combine(AppContext.BaseDirectory, "../../../../../scripts/jetstream/extract-go-js-api.sh");
|
||||
script = Path.GetFullPath(script);
|
||||
if (!File.Exists(script))
|
||||
throw new FileNotFoundException($"missing script: {script}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "bash",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add(script);
|
||||
|
||||
using var process = Process.Start(psi) ?? throw new InvalidOperationException("failed to start inventory script");
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var errors = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException($"inventory script failed: {errors}");
|
||||
|
||||
var subjects = output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
|
||||
return new JetStreamApiInventory(subjects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamApiProtocolIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Js_api_request_over_pub_reply_returns_response_message()
|
||||
{
|
||||
await using var server = await ServerFixture.StartJetStreamEnabledAsync();
|
||||
var response = await server.RequestAsync("$JS.API.INFO", "{}", timeoutMs: 1000);
|
||||
|
||||
response.ShouldContain("\"error\"");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
private ServerFixture(NatsServer server, CancellationTokenSource cts)
|
||||
{
|
||||
_server = server;
|
||||
_cts = cts;
|
||||
}
|
||||
|
||||
public static async Task<ServerFixture> StartJetStreamEnabledAsync()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-proto-{Guid.NewGuid():N}"),
|
||||
MaxMemoryStore = 1024 * 1024,
|
||||
MaxFileStore = 10 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ServerFixture(server, cts);
|
||||
}
|
||||
|
||||
public async Task<string> RequestAsync(string subject, string payload, int timeoutMs)
|
||||
{
|
||||
await using var conn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{_server.Port}" });
|
||||
await conn.ConnectAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs));
|
||||
var response = await conn.RequestAsync<string, string>(subject, payload, cancellationToken: timeout.Token);
|
||||
return response.Data ?? string.Empty;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
32
tests/NATS.Server.Tests/JetStreamApiRouterCoverageTests.cs
Normal file
32
tests/NATS.Server.Tests/JetStreamApiRouterCoverageTests.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamApiRouterCoverageTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("$JS.API.STREAM.UPDATE.ORDERS")]
|
||||
[InlineData("$JS.API.STREAM.DELETE.ORDERS")]
|
||||
[InlineData("$JS.API.STREAM.PURGE.ORDERS")]
|
||||
[InlineData("$JS.API.CONSUMER.DELETE.ORDERS.DUR")]
|
||||
[InlineData("$JS.API.CONSUMER.MSG.NEXT.ORDERS.DUR")]
|
||||
public void Router_recognizes_remaining_subject_families(string subject)
|
||||
{
|
||||
var streams = new StreamManager();
|
||||
_ = streams.CreateOrUpdate(new NATS.Server.JetStream.Models.StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Subjects = ["orders.*"],
|
||||
});
|
||||
var consumers = new ConsumerManager();
|
||||
_ = consumers.CreateOrUpdate("ORDERS", new NATS.Server.JetStream.Models.ConsumerConfig
|
||||
{
|
||||
DurableName = "DUR",
|
||||
});
|
||||
|
||||
var router = new JetStreamApiRouter(streams, consumers);
|
||||
var response = router.Route(subject, "{}"u8);
|
||||
response.Error.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
19
tests/NATS.Server.Tests/JetStreamClusterControlApiTests.cs
Normal file
19
tests/NATS.Server.Tests/JetStreamClusterControlApiTests.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamClusterControlApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_leader_stepdown_and_meta_stepdown_endpoints_return_success_shape()
|
||||
{
|
||||
await using var fx = await JetStreamClusterFixture.StartAsync(nodes: 3);
|
||||
|
||||
var create = await fx.CreateStreamAsync("ORDERS", replicas: 3);
|
||||
create.Error.ShouldBeNull();
|
||||
|
||||
var streamStepdown = await fx.RequestAsync("$JS.API.STREAM.LEADER.STEPDOWN.ORDERS", "{}");
|
||||
streamStepdown.Success.ShouldBeTrue();
|
||||
|
||||
var metaStepdown = await fx.RequestAsync("$JS.API.META.LEADER.STEPDOWN", "{}");
|
||||
metaStepdown.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
14
tests/NATS.Server.Tests/JetStreamConsumerControlApiTests.cs
Normal file
14
tests/NATS.Server.Tests/JetStreamConsumerControlApiTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerControlApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Consumer_pause_reset_unpin_mutate_state()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithPullConsumerAsync();
|
||||
|
||||
(await fx.RequestLocalAsync("$JS.API.CONSUMER.PAUSE.ORDERS.PULL", "{\"pause\":true}")).Success.ShouldBeTrue();
|
||||
(await fx.RequestLocalAsync("$JS.API.CONSUMER.RESET.ORDERS.PULL", "{}")).Success.ShouldBeTrue();
|
||||
(await fx.RequestLocalAsync("$JS.API.CONSUMER.UNPIN.ORDERS.PULL", "{}")).Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
17
tests/NATS.Server.Tests/JetStreamConsumerListApiTests.cs
Normal file
17
tests/NATS.Server.Tests/JetStreamConsumerListApiTests.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerListApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Consumer_names_list_and_delete_are_supported()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithPullConsumerAsync();
|
||||
|
||||
var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.ORDERS", "{}");
|
||||
names.ConsumerNames.ShouldNotBeNull();
|
||||
names.ConsumerNames.ShouldContain("PULL");
|
||||
|
||||
var del = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.ORDERS.PULL", "{}");
|
||||
del.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/JetStreamConsumerNextApiTests.cs
Normal file
15
tests/NATS.Server.Tests/JetStreamConsumerNextApiTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerNextApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Consumer_msg_next_respects_batch_request()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithPullConsumerAsync();
|
||||
_ = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var next = await fx.RequestLocalAsync("$JS.API.CONSUMER.MSG.NEXT.ORDERS.PULL", "{\"batch\":1}");
|
||||
next.PullBatch.ShouldNotBeNull();
|
||||
next.PullBatch!.Messages.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/JetStreamDirectGetApiTests.cs
Normal file
15
tests/NATS.Server.Tests/JetStreamDirectGetApiTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamDirectGetApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Direct_get_returns_message_without_stream_info_wrapper()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
var ack = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var direct = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.ORDERS", $"{{\"seq\":{ack.Seq}}}");
|
||||
direct.DirectMessage.ShouldNotBeNull();
|
||||
direct.DirectMessage!.Payload.ShouldBe("1");
|
||||
}
|
||||
}
|
||||
14
tests/NATS.Server.Tests/JetStreamExpectedHeaderTests.cs
Normal file
14
tests/NATS.Server.Tests/JetStreamExpectedHeaderTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamExpectedHeaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Expected_last_sequence_mismatch_returns_error()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var ack = await fx.PublishWithExpectedLastSeqAsync("orders.created", "2", expectedLastSeq: 999);
|
||||
ack.ErrorCode.ShouldBe(10071);
|
||||
}
|
||||
}
|
||||
50
tests/NATS.Server.Tests/JetStreamIntegrationMatrix.cs
Normal file
50
tests/NATS.Server.Tests/JetStreamIntegrationMatrix.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
internal static class JetStreamIntegrationMatrix
|
||||
{
|
||||
public static async Task<(bool Success, string Details)> RunScenarioAsync(string scenario)
|
||||
{
|
||||
try
|
||||
{
|
||||
return scenario switch
|
||||
{
|
||||
"stream-msg-delete-roundtrip" => await StreamMsgDeleteRoundtripAsync(),
|
||||
"consumer-msg-next-no-wait" => await ConsumerNextNoWaitAsync(),
|
||||
"direct-get-by-sequence" => await DirectGetBySequenceAsync(),
|
||||
_ => (false, $"unknown scenario: {scenario}"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string Details)> StreamMsgDeleteRoundtripAsync()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
var ack = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var del = await fx.RequestLocalAsync("$JS.API.STREAM.MSG.DELETE.ORDERS", $"{{\"seq\":{ack.Seq}}}");
|
||||
if (!del.Success)
|
||||
return (false, "stream msg delete did not return success");
|
||||
|
||||
var get = await fx.RequestLocalAsync("$JS.API.STREAM.MSG.GET.ORDERS", $"{{\"seq\":{ack.Seq}}}");
|
||||
return (get.Error != null, get.Error == null ? "deleted message was still retrievable" : string.Empty);
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string Details)> ConsumerNextNoWaitAsync()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithPullConsumerAsync();
|
||||
var batch = await fx.FetchWithNoWaitAsync("ORDERS", "PULL", 1);
|
||||
return (batch.Messages.Count == 0 && !batch.TimedOut, batch.Messages.Count == 0 ? "batch timed out unexpectedly" : "expected empty pull batch");
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string Details)> DirectGetBySequenceAsync()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
var ack = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
var direct = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.ORDERS", $"{{\"seq\":{ack.Seq}}}");
|
||||
return (direct.DirectMessage?.Payload == "1", direct.DirectMessage == null ? "direct message payload missing" : "unexpected direct message payload");
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,12 @@ namespace NATS.Server.Tests;
|
||||
public class JetStreamIntegrationMatrixTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("stream-create-update-delete")]
|
||||
[InlineData("pull-consumer-ack-redelivery")]
|
||||
[InlineData("mirror-source")]
|
||||
public async Task Integration_matrix_case_passes(string scenario)
|
||||
[InlineData("stream-msg-delete-roundtrip")]
|
||||
[InlineData("consumer-msg-next-no-wait")]
|
||||
[InlineData("direct-get-by-sequence")]
|
||||
public async Task Integration_matrix_executes_real_server_scenarios(string scenario)
|
||||
{
|
||||
var result = await JetStreamIntegrationMatrix.RunScenarioAsync(scenario);
|
||||
result.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JetStreamIntegrationMatrix
|
||||
{
|
||||
private static readonly HashSet<string> SupportedScenarios = new(StringComparer.Ordinal)
|
||||
{
|
||||
"stream-create-update-delete",
|
||||
"pull-consumer-ack-redelivery",
|
||||
"mirror-source",
|
||||
};
|
||||
|
||||
public static Task<(bool Success, string Details)> RunScenarioAsync(string scenario)
|
||||
{
|
||||
if (SupportedScenarios.Contains(scenario))
|
||||
return Task.FromResult((true, string.Empty));
|
||||
|
||||
return Task.FromResult((false, $"unknown matrix scenario: {scenario}"));
|
||||
result.Success.ShouldBeTrue(result.Details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Cluster;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
@@ -23,18 +24,24 @@ internal sealed class JetStreamClusterFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly JetStreamMetaGroup _metaGroup;
|
||||
private readonly StreamManager _streamManager;
|
||||
private readonly ConsumerManager _consumerManager;
|
||||
private readonly JetStreamApiRouter _router;
|
||||
|
||||
private JetStreamClusterFixture(JetStreamMetaGroup metaGroup, StreamManager streamManager)
|
||||
private JetStreamClusterFixture(JetStreamMetaGroup metaGroup, StreamManager streamManager, ConsumerManager consumerManager, JetStreamApiRouter router)
|
||||
{
|
||||
_metaGroup = metaGroup;
|
||||
_streamManager = streamManager;
|
||||
_consumerManager = consumerManager;
|
||||
_router = router;
|
||||
}
|
||||
|
||||
public static Task<JetStreamClusterFixture> StartAsync(int nodes)
|
||||
{
|
||||
var meta = new JetStreamMetaGroup(nodes);
|
||||
var streamManager = new StreamManager(meta);
|
||||
return Task.FromResult(new JetStreamClusterFixture(meta, streamManager));
|
||||
var consumerManager = new ConsumerManager(meta);
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
|
||||
return Task.FromResult(new JetStreamClusterFixture(meta, streamManager, consumerManager, router));
|
||||
}
|
||||
|
||||
public Task<NATS.Server.JetStream.Api.JetStreamApiResponse> CreateStreamAsync(string name, int replicas)
|
||||
@@ -50,5 +57,10 @@ internal sealed class JetStreamClusterFixture : IAsyncDisposable
|
||||
|
||||
public Task<MetaGroupState> GetMetaStateAsync() => Task.FromResult(_metaGroup.GetState());
|
||||
|
||||
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
|
||||
{
|
||||
return Task.FromResult(_router.Route(subject, System.Text.Encoding.UTF8.GetBytes(payload)));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
43
tests/NATS.Server.Tests/JetStreamMonitoringParityTests.cs
Normal file
43
tests/NATS.Server.Tests/JetStreamMonitoringParityTests.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Monitoring;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamMonitoringParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Jsz_and_varz_include_expanded_runtime_fields()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-monitor-{Guid.NewGuid():N}"),
|
||||
MaxMemoryStore = 1024 * 1024,
|
||||
MaxFileStore = 10 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
_ = server.JetStreamApiRouter!.Route("$JS.API.STREAM.CREATE.ORDERS", "{\"subjects\":[\"orders.*\"]}"u8);
|
||||
_ = server.JetStreamApiRouter!.Route("$JS.API.CONSUMER.CREATE.ORDERS.PULL", "{\"durable_name\":\"PULL\",\"filter_subject\":\"orders.created\"}"u8);
|
||||
|
||||
var jsz = new JszHandler(server, options).Build();
|
||||
jsz.Streams.ShouldBeGreaterThanOrEqualTo(1);
|
||||
jsz.Consumers.ShouldBeGreaterThanOrEqualTo(1);
|
||||
jsz.ApiTotal.ShouldBeGreaterThanOrEqualTo((ulong)0);
|
||||
|
||||
var varz = await new VarzHandler(server, options).HandleVarzAsync();
|
||||
varz.JetStream.Stats.Api.Total.ShouldBeGreaterThanOrEqualTo((ulong)0);
|
||||
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
22
tests/NATS.Server.Tests/JetStreamPolicyValidationTests.cs
Normal file
22
tests/NATS.Server.Tests/JetStreamPolicyValidationTests.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Validation;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPolicyValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validator_rejects_invalid_policy_combinations()
|
||||
{
|
||||
var cfg = new StreamConfig
|
||||
{
|
||||
Name = "S",
|
||||
Subjects = ["s.*"],
|
||||
Retention = RetentionPolicy.WorkQueue,
|
||||
MaxConsumers = 0,
|
||||
};
|
||||
|
||||
var result = JetStreamConfigValidator.Validate(cfg);
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPullConsumerContractTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Pull_fetch_no_wait_returns_immediately_when_empty()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithPullConsumerAsync();
|
||||
|
||||
var batch = await fx.FetchWithNoWaitAsync("ORDERS", "PULL", batch: 1);
|
||||
batch.Messages.Count.ShouldBe(0);
|
||||
batch.TimedOut.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPushConsumerContractTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Ack_all_advances_floor_and_clears_pending_before_sequence()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithAckAllConsumerAsync();
|
||||
await fx.PublishManyAsync("orders.created", ["1", "2", "3"]);
|
||||
|
||||
var first = await fx.FetchAsync("ORDERS", "ACKALL", 3);
|
||||
await fx.AckAllAsync("ORDERS", "ACKALL", first.Messages.Last().Sequence);
|
||||
|
||||
var pending = await fx.GetPendingCountAsync("ORDERS", "ACKALL");
|
||||
pending.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
17
tests/NATS.Server.Tests/JetStreamSnapshotRestoreApiTests.cs
Normal file
17
tests/NATS.Server.Tests/JetStreamSnapshotRestoreApiTests.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamSnapshotRestoreApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Snapshot_then_restore_reconstructs_messages()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var snap = await fx.RequestLocalAsync("$JS.API.STREAM.SNAPSHOT.ORDERS", "{}");
|
||||
snap.Snapshot.ShouldNotBeNull();
|
||||
|
||||
var restore = await fx.RequestLocalAsync("$JS.API.STREAM.RESTORE.ORDERS", snap.Snapshot!.Payload);
|
||||
restore.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
18
tests/NATS.Server.Tests/JetStreamStoreIndexTests.cs
Normal file
18
tests/NATS.Server.Tests/JetStreamStoreIndexTests.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStoreIndexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Store_can_get_last_message_by_subject()
|
||||
{
|
||||
var store = new MemStore();
|
||||
await store.AppendAsync("orders.created", "1"u8.ToArray(), default);
|
||||
await store.AppendAsync("orders.updated", "2"u8.ToArray(), default);
|
||||
await store.AppendAsync("orders.created", "3"u8.ToArray(), default);
|
||||
|
||||
var last = await store.LoadLastBySubjectAsync("orders.created", default);
|
||||
last!.Payload.Span.SequenceEqual("3"u8).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
16
tests/NATS.Server.Tests/JetStreamStreamLifecycleApiTests.cs
Normal file
16
tests/NATS.Server.Tests/JetStreamStreamLifecycleApiTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamLifecycleApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_update_and_delete_roundtrip()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
|
||||
var update = await fx.RequestLocalAsync("$JS.API.STREAM.UPDATE.ORDERS", "{\"subjects\":[\"orders.v2.*\"]}");
|
||||
update.Error.ShouldBeNull();
|
||||
|
||||
var delete = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.ORDERS", "{}");
|
||||
delete.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
16
tests/NATS.Server.Tests/JetStreamStreamListApiTests.cs
Normal file
16
tests/NATS.Server.Tests/JetStreamStreamListApiTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamListApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_names_and_list_return_created_streams()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.INVOICES", "{\"subjects\":[\"invoices.*\"]}");
|
||||
|
||||
var names = await fx.RequestLocalAsync("$JS.API.STREAM.NAMES", "{}");
|
||||
names.StreamNames.ShouldNotBeNull();
|
||||
names.StreamNames.ShouldContain("ORDERS");
|
||||
names.StreamNames.ShouldContain("INVOICES");
|
||||
}
|
||||
}
|
||||
20
tests/NATS.Server.Tests/JetStreamStreamMessageApiTests.cs
Normal file
20
tests/NATS.Server.Tests/JetStreamStreamMessageApiTests.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamMessageApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_msg_get_delete_and_purge_change_state()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
var ack = await fx.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var get = await fx.RequestLocalAsync("$JS.API.STREAM.MSG.GET.ORDERS", $"{{\"seq\":{ack.Seq}}}");
|
||||
get.StreamMessage.ShouldNotBeNull();
|
||||
|
||||
var del = await fx.RequestLocalAsync("$JS.API.STREAM.MSG.DELETE.ORDERS", $"{{\"seq\":{ack.Seq}}}");
|
||||
del.Success.ShouldBeTrue();
|
||||
|
||||
var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.ORDERS", "{}");
|
||||
purge.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
19
tests/NATS.Server.Tests/RaftSafetyContractTests.cs
Normal file
19
tests/NATS.Server.Tests/RaftSafetyContractTests.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftSafetyContractTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Follower_rejects_stale_term_vote_and_append()
|
||||
{
|
||||
var node = new RaftNode("n1");
|
||||
node.StartElection(clusterSize: 1);
|
||||
|
||||
var staleVote = node.GrantVote(term: node.Term - 1);
|
||||
staleVote.Granted.ShouldBeFalse();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await node.TryAppendFromLeaderAsync(new RaftLogEntry(1, node.Term - 1, "cmd"), default));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,18 @@ public class StreamStoreContractTests
|
||||
public ValueTask<StoredMessage?> LoadAsync(ulong sequence, CancellationToken ct)
|
||||
=> ValueTask.FromResult<StoredMessage?>(null);
|
||||
|
||||
public ValueTask<StoredMessage?> LoadLastBySubjectAsync(string subject, CancellationToken ct)
|
||||
=> ValueTask.FromResult<StoredMessage?>(null);
|
||||
|
||||
public ValueTask<bool> RemoveAsync(ulong sequence, CancellationToken ct)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask PurgeAsync(CancellationToken ct) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<byte[]> CreateSnapshotAsync(CancellationToken ct)
|
||||
=> ValueTask.FromResult(Array.Empty<byte>());
|
||||
|
||||
public ValueTask RestoreSnapshotAsync(ReadOnlyMemory<byte> snapshot, CancellationToken ct)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user