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 StackExchange.Redis; using StellaOps.Notify.Models; using StellaOps.Notify.Queue; using StellaOps.Notify.Queue.Redis; using Xunit; namespace StellaOps.Notify.Queue.Tests; public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime { private readonly RedisTestcontainer _redis; private string? _skipReason; public RedisNotifyDeliveryQueueTests() { var configuration = new RedisTestcontainerConfiguration(); _redis = new TestcontainersBuilder() .WithDatabase(configuration) .Build(); } public async Task InitializeAsync() { try { await _redis.StartAsync(); } catch (Exception ex) { _skipReason = $"Redis-backed delivery tests skipped: {ex.Message}"; } } public async Task DisposeAsync() { if (_skipReason is not null) { return; } await _redis.DisposeAsync().AsTask(); } [Fact] public async Task Publish_ShouldDeduplicate_ByDeliveryId() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); var delivery = TestData.CreateDelivery(); var message = new NotifyDeliveryQueueMessage( delivery, channelId: "channel-1", 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_ShouldRescheduleDelivery() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(); await using var queue = CreateQueue(options); await queue.PublishAsync(new NotifyDeliveryQueueMessage( TestData.CreateDelivery(), channelId: "channel-retry", channelType: NotifyChannelType.Teams)); var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single(); lease.Attempt.Should().Be(1); await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry); var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single(); retried.Attempt.Should().Be(2); await retried.AcknowledgeAsync(); } [Fact] public async Task Release_RetryBeyondMax_ShouldDeadLetter() { if (SkipIfUnavailable()) { return; } var options = CreateOptions(static opts => { opts.MaxDeliveryAttempts = 2; opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead"; }); await using var queue = CreateQueue(options); await queue.PublishAsync(new NotifyDeliveryQueueMessage( TestData.CreateDelivery(), channelId: "channel-dead", channelType: NotifyChannelType.Email)); var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single(); await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry); var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single(); await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry); await Task.Delay(100); var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString); var db = mux.GetDatabase(); var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0"); deadLetters.Should().NotBeEmpty(); } private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options) { return new RedisNotifyDeliveryQueue( options, options.Redis, NullLogger.Instance, TimeProvider.System, async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false)); } private NotifyDeliveryQueueOptions CreateOptions(Action? configure = null) { var opts = new NotifyDeliveryQueueOptions { Transport = NotifyQueueTransportKind.Redis, DefaultLeaseDuration = TimeSpan.FromSeconds(1), MaxDeliveryAttempts = 3, RetryInitialBackoff = TimeSpan.FromMilliseconds(10), RetryMaxBackoff = TimeSpan.FromMilliseconds(50), ClaimIdleThreshold = TimeSpan.FromSeconds(1), Redis = new NotifyRedisDeliveryQueueOptions { ConnectionString = _redis.ConnectionString, StreamName = "notify:deliveries:test", ConsumerGroup = "notify-delivery-tests", IdempotencyKeyPrefix = "notify:deliveries:test:idemp:" } }; configure?.Invoke(opts); return opts; } private bool SkipIfUnavailable() => _skipReason is not null; private static class TestData { public static NotifyDelivery CreateDelivery() { var now = DateTimeOffset.UtcNow; return NotifyDelivery.Create( deliveryId: Guid.NewGuid().ToString("n"), tenantId: "tenant-1", ruleId: "rule-1", actionId: "action-1", eventId: Guid.NewGuid(), kind: "scanner.report.ready", status: NotifyDeliveryStatus.Pending, createdAt: now, metadata: new Dictionary { ["integration"] = "tests" }); } } }