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 StellaOps.Notify.Models; using StellaOps.Notify.Queue; using StellaOps.Notify.Queue.Nats; using Xunit; namespace StellaOps.Notify.Queue.Tests; public sealed class NatsNotifyEventQueueTests : IAsyncLifetime { private readonly TestcontainersContainer _nats; private string? _skipReason; public NatsNotifyEventQueueTests() { _nats = new TestcontainersBuilder() .WithImage("nats:2.10-alpine") .WithCleanUp(true) .WithName($"nats-notify-tests-{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 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_ByIdempotencyKey() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); var notifyEvent = TestData.CreateEvent("tenant-a"); var message = new NotifyQueueEventMessage( notifyEvent, options.Nats.Subject, traceId: "trace-1"); 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 Lease_Acknowledge_ShouldRemoveMessage() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); var notifyEvent = TestData.CreateEvent("tenant-b"); var message = new NotifyQueueEventMessage( notifyEvent, options.Nats.Subject, traceId: "trace-xyz", attributes: new Dictionary { { "source", "scanner" } }); await queue.PublishAsync(message); var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2))); leases.Should().ContainSingle(); var lease = leases[0]; lease.Attempt.Should().BeGreaterThanOrEqualTo(1); lease.Message.Event.EventId.Should().Be(notifyEvent.EventId); lease.TraceId.Should().Be("trace-xyz"); lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner"); await lease.AcknowledgeAsync(); var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1))); afterAck.Should().BeEmpty(); } [Fact] public async Task Lease_ShouldPreserveOrdering() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); var first = TestData.CreateEvent(); var second = TestData.CreateEvent(); await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject)); await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject)); var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2))); leases.Should().HaveCount(2); leases.Select(x => x.Message.Event.EventId) .Should() .ContainInOrder(first.EventId, second.EventId); } [Fact] public async Task ClaimExpired_ShouldReassignLease() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); var notifyEvent = TestData.CreateEvent(); await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject)); var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500))); leases.Should().ContainSingle(); await Task.Delay(200); var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100))); claimed.Should().ContainSingle(); var lease = claimed[0]; lease.Consumer.Should().Be("worker-reclaim"); lease.Message.Event.EventId.Should().Be(notifyEvent.EventId); await lease.AcknowledgeAsync(); } private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options) { return new NatsNotifyEventQueue( options, options.Nats, NullLogger.Instance, TimeProvider.System); } private NotifyEventQueueOptions CreateOptions() { var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}"; return new NotifyEventQueueOptions { Transport = NotifyQueueTransportKind.Nats, DefaultLeaseDuration = TimeSpan.FromSeconds(2), MaxDeliveryAttempts = 3, RetryInitialBackoff = TimeSpan.FromMilliseconds(50), RetryMaxBackoff = TimeSpan.FromSeconds(1), Nats = new NotifyNatsEventQueueOptions { Url = connectionUrl, Stream = "NOTIFY_TEST", Subject = "notify.test.events", DeadLetterStream = "NOTIFY_TEST_DEAD", DeadLetterSubject = "notify.test.events.dead", DurableConsumer = "notify-test-consumer", MaxAckPending = 32, AckWait = TimeSpan.FromSeconds(2), RetryDelay = TimeSpan.FromMilliseconds(100), IdleHeartbeat = TimeSpan.FromMilliseconds(100) } }; } private bool SkipIfUnavailable() => _skipReason is not null; private static class TestData { public static NotifyEvent CreateEvent(string tenant = "tenant-1") { return NotifyEvent.Create( Guid.NewGuid(), kind: "scanner.report.ready", tenant: tenant, ts: DateTimeOffset.UtcNow, payload: new JsonObject { ["summary"] = "event" }); } } }