224 lines
7.4 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|