226 lines
7.2 KiB
C#
226 lines
7.2 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 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<TestcontainersContainer>()
|
|
.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<string, string> { { "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<NatsNotifyEventQueue>.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"
|
|
});
|
|
}
|
|
}
|
|
}
|