Files
git.stella-ops.org/src/Notify/__Tests/StellaOps.Notify.Queue.Tests/NatsNotifyDeliveryQueueTests.cs
2025-10-28 15:10:40 +02:00

224 lines
7.4 KiB
C#

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<TestcontainersContainer>()
.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<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.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<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? 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);
}
}
}