using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Nodes; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Configurations; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using NATS.Client.Core; using NATS.Client.JetStream; using NATS.Client.JetStream.Models; using StellaOps.Notify.Models; using StellaOps.Notify.Queue; using StellaOps.Notify.Queue.Nats; using Xunit; namespace StellaOps.Notify.Queue.Tests; public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime { private readonly TestcontainersContainer _nats; private string? _skipReason; public NatsNotifyDeliveryQueueTests() { _nats = new TestcontainersBuilder() .WithImage("nats:2.10-alpine") .WithCleanUp(true) .WithName($"nats-notify-delivery-{Guid.NewGuid():N}") .WithPortBinding(4222, true) .WithCommand("--jetstream") .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222)) .Build(); } public async Task InitializeAsync() { try { await _nats.StartAsync(); } catch (Exception ex) { _skipReason = $"NATS-backed delivery tests skipped: {ex.Message}"; } } public async Task DisposeAsync() { if (_skipReason is not null) { return; } await _nats.DisposeAsync().ConfigureAwait(false); } [Fact] public async Task Publish_ShouldDeduplicate_ByDeliveryId() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); var delivery = TestData.CreateDelivery("tenant-a"); var message = new NotifyDeliveryQueueMessage( delivery, channelId: "chan-a", channelType: NotifyChannelType.Slack); var first = await queue.PublishAsync(message); first.Deduplicated.Should().BeFalse(); var second = await queue.PublishAsync(message); second.Deduplicated.Should().BeTrue(); second.MessageId.Should().Be(first.MessageId); } [Fact] public async Task Release_Retry_ShouldReschedule() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); await queue.PublishAsync(new NotifyDeliveryQueueMessage( TestData.CreateDelivery(), channelId: "chan-retry", channelType: NotifyChannelType.Teams)); var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single(); await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry); var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single(); retried.Attempt.Should().BeGreaterThan(lease.Attempt); await retried.AcknowledgeAsync(); } [Fact] public async Task Release_RetryBeyondMax_ShouldDeadLetter() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(static opts => { opts.MaxDeliveryAttempts = 2; opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST"; opts.Nats.DeadLetterSubject = "notify.delivery.dead.test"; }); await using var queue = CreateQueue(options); await queue.PublishAsync(new NotifyDeliveryQueueMessage( TestData.CreateDelivery(), channelId: "chan-dead", channelType: NotifyChannelType.Webhook)); var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single(); await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry); var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single(); await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry); await Task.Delay(200); await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! }); await connection.ConnectAsync(); var js = new NatsJSContext(connection); var consumerConfig = new ConsumerConfig { DurableName = "notify-delivery-dead-test", DeliverPolicy = ConsumerConfigDeliverPolicy.All, AckPolicy = ConsumerConfigAckPolicy.Explicit }; var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig); var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) }; NatsJSMsg? dlqMsg = null; await foreach (var msg in consumer.FetchAsync(NatsRawSerializer.Default, fetchOpts)) { dlqMsg = msg; await msg.AckAsync(new AckOpts()); break; } dlqMsg.Should().NotBeNull(); } private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options) { return new NatsNotifyDeliveryQueue( options, options.Nats, NullLogger.Instance, TimeProvider.System); } private NotifyDeliveryQueueOptions CreateOptions(Action? configure = null) { var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}"; var opts = new NotifyDeliveryQueueOptions { Transport = NotifyQueueTransportKind.Nats, DefaultLeaseDuration = TimeSpan.FromSeconds(2), MaxDeliveryAttempts = 3, RetryInitialBackoff = TimeSpan.FromMilliseconds(20), RetryMaxBackoff = TimeSpan.FromMilliseconds(200), Nats = new NotifyNatsDeliveryQueueOptions { Url = url, Stream = "NOTIFY_DELIVERY_TEST", Subject = "notify.delivery.test", DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD", DeadLetterSubject = "notify.delivery.test.dead", DurableConsumer = "notify-delivery-tests", MaxAckPending = 32, AckWait = TimeSpan.FromSeconds(2), RetryDelay = TimeSpan.FromMilliseconds(100), IdleHeartbeat = TimeSpan.FromMilliseconds(200) } }; configure?.Invoke(opts); return opts; } private bool SkipIfUnavailable() => _skipReason is not null; private static class TestData { public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1") { return NotifyDelivery.Create( deliveryId: Guid.NewGuid().ToString("n"), tenantId: tenantId, ruleId: "rule-1", actionId: "action-1", eventId: Guid.NewGuid(), kind: "scanner.report.ready", status: NotifyDeliveryStatus.Pending, createdAt: DateTimeOffset.UtcNow); } } }