198 lines
6.4 KiB
C#
198 lines
6.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 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"
|
|
});
|
|
}
|
|
}
|
|
}
|