up
This commit is contained in:
223
src/StellaOps.Notify.Queue.Tests/NatsNotifyDeliveryQueueTests.cs
Normal file
223
src/StellaOps.Notify.Queue.Tests/NatsNotifyDeliveryQueueTests.cs
Normal file
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
}
|
||||
225
src/StellaOps.Notify.Queue.Tests/NatsNotifyEventQueueTests.cs
Normal file
225
src/StellaOps.Notify.Queue.Tests/NatsNotifyEventQueueTests.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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<RedisTestcontainer>()
|
||||
.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<RedisNotifyDeliveryQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? 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<string, string>
|
||||
{
|
||||
["integration"] = "tests"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
220
src/StellaOps.Notify.Queue.Tests/RedisNotifyEventQueueTests.cs
Normal file
220
src/StellaOps.Notify.Queue.Tests/RedisNotifyEventQueueTests.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
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 RedisNotifyEventQueueTests : IAsyncLifetime
|
||||
{
|
||||
private readonly RedisTestcontainer _redis;
|
||||
private string? _skipReason;
|
||||
|
||||
public RedisNotifyEventQueueTests()
|
||||
{
|
||||
var configuration = new RedisTestcontainerConfiguration();
|
||||
_redis = new TestcontainersBuilder<RedisTestcontainer>()
|
||||
.WithDatabase(configuration)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _redis.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _redis.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
|
||||
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
|
||||
|
||||
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: "tenant-b");
|
||||
var message = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
options.Redis.Streams[0].Stream,
|
||||
traceId: "trace-123",
|
||||
attributes: new Dictionary<string, string> { { "source", "scanner" } });
|
||||
|
||||
await queue.PublishAsync(message);
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
var lease = leases[0];
|
||||
lease.Attempt.Should().Be(1);
|
||||
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
|
||||
lease.TraceId.Should().Be("trace-123");
|
||||
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
|
||||
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
|
||||
afterAck.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lease_ShouldPreserveOrdering()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var stream = options.Redis.Streams[0].Stream;
|
||||
var firstEvent = TestData.CreateEvent();
|
||||
var secondEvent = TestData.CreateEvent();
|
||||
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
|
||||
leases.Should().HaveCount(2);
|
||||
|
||||
leases.Select(l => l.Message.Event.EventId)
|
||||
.Should()
|
||||
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.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.Redis.Streams[0].Stream));
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
// Ensure the message has been pending long enough for claim.
|
||||
await Task.Delay(50);
|
||||
|
||||
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
|
||||
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 RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
|
||||
{
|
||||
return new RedisNotifyEventQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisNotifyEventQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private NotifyEventQueueOptions CreateOptions()
|
||||
{
|
||||
var streamOptions = new NotifyRedisEventStreamOptions
|
||||
{
|
||||
Stream = "notify:test:events",
|
||||
ConsumerGroup = "notify-test-consumers",
|
||||
IdempotencyKeyPrefix = "notify:test:idemp:",
|
||||
ApproximateMaxLength = 1024
|
||||
};
|
||||
|
||||
var redisOptions = new NotifyRedisEventQueueOptions
|
||||
{
|
||||
ConnectionString = _redis.ConnectionString,
|
||||
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
|
||||
};
|
||||
|
||||
return new NotifyEventQueueOptions
|
||||
{
|
||||
Transport = NotifyQueueTransportKind.Redis,
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
|
||||
Redis = redisOptions
|
||||
};
|
||||
}
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user