up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,223 +1,223 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user