Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,667 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AtLeastOnceDeliveryTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-009 - At-least-once delivery with consumer idempotency
|
||||
// Description: Integration tests verifying at-least-once delivery semantics:
|
||||
// - Messages are never lost (guaranteed delivery)
|
||||
// - Consumer idempotency correctly handles duplicate deliveries
|
||||
// - Lease expiration triggers redelivery
|
||||
// - Simulated failures result in message redelivery
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for at-least-once delivery semantics with consumer idempotency.
|
||||
///
|
||||
/// At-least-once delivery guarantees:
|
||||
/// 1. Every message sent is delivered at least once
|
||||
/// 2. Messages may be delivered multiple times (redelivery on failure)
|
||||
/// 3. Consumer idempotency handles duplicate deliveries
|
||||
/// 4. No message is ever lost, even under failure conditions
|
||||
/// </summary>
|
||||
[Collection(ValkeyIntegrationTestCollection.Name)]
|
||||
public sealed class AtLeastOnceDeliveryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ValkeyContainerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private ValkeyConnectionFactory? _connectionFactory;
|
||||
private ValkeyIdempotencyStore? _idempotencyStore;
|
||||
|
||||
public AtLeastOnceDeliveryTests(ValkeyContainerFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_connectionFactory = _fixture.CreateConnectionFactory();
|
||||
_idempotencyStore = new ValkeyIdempotencyStore(
|
||||
_connectionFactory,
|
||||
$"test-consumer-{Guid.NewGuid():N}",
|
||||
null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_connectionFactory is not null)
|
||||
{
|
||||
await _connectionFactory.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region At-Least-Once Delivery Guarantee Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_MessageSent_IsDeliveredAtLeastOnce()
|
||||
{
|
||||
// Arrange - Producer sends message
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var messageId = Guid.NewGuid();
|
||||
var message = new TestMessage
|
||||
{
|
||||
Id = messageId,
|
||||
Content = "At-least-once test message"
|
||||
};
|
||||
|
||||
// Act - Send message
|
||||
var enqueueResult = await queue.EnqueueAsync(message);
|
||||
enqueueResult.Success.Should().BeTrue("message should be accepted by the queue");
|
||||
|
||||
// Act - Consumer receives message
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert - Message is delivered
|
||||
leases.Should().HaveCount(1, "message must be delivered at least once");
|
||||
leases[0].Message.Id.Should().Be(messageId);
|
||||
leases[0].Message.Content.Should().Be("At-least-once test message");
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine($"Message {messageId} delivered successfully");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_UnacknowledgedLease_MessageRedelivered()
|
||||
{
|
||||
// Arrange - Create queue with short lease duration
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.DefaultLeaseDuration = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
var queue = CreateQueue<TestMessage>(queueOptions);
|
||||
var messageId = Guid.NewGuid();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "Redelivery test" });
|
||||
|
||||
// Act - Lease message but don't acknowledge (simulating consumer crash)
|
||||
var firstLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
firstLease.Should().HaveCount(1);
|
||||
firstLease[0].Message.Id.Should().Be(messageId);
|
||||
|
||||
// Don't acknowledge - simulate crash
|
||||
_output.WriteLine("Simulating consumer crash (not acknowledging message)");
|
||||
|
||||
// Wait for lease to expire
|
||||
await Task.Delay(500);
|
||||
|
||||
// Act - Claim expired message (automatic redelivery)
|
||||
var redelivered = await queue.ClaimExpiredAsync(new ClaimRequest
|
||||
{
|
||||
BatchSize = 10,
|
||||
MinIdleTime = TimeSpan.FromMilliseconds(200),
|
||||
MinDeliveryAttempts = 1
|
||||
});
|
||||
|
||||
// Assert - Message is redelivered
|
||||
redelivered.Should().HaveCount(1, "message must be redelivered after lease expiration");
|
||||
redelivered[0].Message.Id.Should().Be(messageId);
|
||||
redelivered[0].Attempt.Should().BeGreaterThan(1, "this should be a redelivery");
|
||||
|
||||
await redelivered[0].AcknowledgeAsync();
|
||||
_output.WriteLine($"Message {messageId} successfully redelivered on attempt {redelivered[0].Attempt}");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_MultipleMessages_AllDelivered()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
const int messageCount = 100;
|
||||
var sentIds = new HashSet<Guid>();
|
||||
|
||||
// Act - Send multiple messages
|
||||
for (int i = 0; i < messageCount; i++)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
sentIds.Add(id);
|
||||
await queue.EnqueueAsync(new TestMessage { Id = id, Content = $"Message-{i}" });
|
||||
}
|
||||
|
||||
// Act - Receive all messages
|
||||
var receivedIds = new HashSet<Guid>();
|
||||
int remaining = messageCount;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 20 });
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
receivedIds.Add(lease.Message.Id);
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
remaining -= leases.Count;
|
||||
}
|
||||
|
||||
// Assert - All messages delivered
|
||||
receivedIds.Should().BeEquivalentTo(sentIds, "all sent messages must be delivered");
|
||||
_output.WriteLine($"All {messageCount} messages delivered successfully");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_RetryAfterNack_MessageRedelivered()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.RetryInitialBackoff = TimeSpan.Zero; // Immediate retry for test speed
|
||||
|
||||
var queue = CreateQueue<TestMessage>(queueOptions);
|
||||
var messageId = Guid.NewGuid();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "Retry test" });
|
||||
|
||||
// Act - First delivery, simulate processing failure with retry
|
||||
var firstLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
firstLease.Should().HaveCount(1);
|
||||
firstLease[0].Attempt.Should().Be(1);
|
||||
|
||||
// Nack for retry
|
||||
await firstLease[0].ReleaseAsync(ReleaseDisposition.Retry);
|
||||
_output.WriteLine("Message nacked for retry");
|
||||
|
||||
// Brief delay for retry processing
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - Second delivery after retry
|
||||
var secondLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert - Message is redelivered
|
||||
secondLease.Should().HaveCount(1, "message must be redelivered after nack");
|
||||
secondLease[0].Message.Id.Should().Be(messageId);
|
||||
secondLease[0].Attempt.Should().Be(2, "this should be attempt 2");
|
||||
|
||||
await secondLease[0].AcknowledgeAsync();
|
||||
_output.WriteLine($"Message {messageId} successfully processed on attempt 2");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consumer Idempotency Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_DuplicateProcessing_DetectedAndSkipped()
|
||||
{
|
||||
// Arrange - Create a consumer with idempotency tracking
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var processedMessageIds = new HashSet<Guid>();
|
||||
var processingCount = new Dictionary<Guid, int>();
|
||||
|
||||
var messageId = Guid.NewGuid();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "Idempotency test" });
|
||||
|
||||
// Act - Simulate receiving the message multiple times
|
||||
for (int delivery = 1; delivery <= 3; delivery++)
|
||||
{
|
||||
// Simulate message delivery (could be redelivery)
|
||||
var idempotencyKey = $"consumer-process:{messageId}";
|
||||
var claimResult = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
messageId.ToString(),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (claimResult.IsFirstClaim)
|
||||
{
|
||||
// First time processing this message
|
||||
processedMessageIds.Add(messageId);
|
||||
processingCount[messageId] = 1;
|
||||
_output.WriteLine($"Delivery {delivery}: First processing of message {messageId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Duplicate - skip processing
|
||||
processingCount[messageId] = processingCount.GetValueOrDefault(messageId) + 1;
|
||||
_output.WriteLine($"Delivery {delivery}: Duplicate detected, skipping message {messageId}");
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Message processed exactly once despite multiple deliveries
|
||||
processedMessageIds.Should().HaveCount(1);
|
||||
processingCount[messageId].Should().BeGreaterThan(1, "we simulated multiple deliveries");
|
||||
|
||||
// Cleanup
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
if (leases.Count > 0)
|
||||
{
|
||||
await leases[0].AcknowledgeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_ConcurrentDuplicates_OnlyOneProcessed()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var processedCount = 0;
|
||||
var duplicateCount = 0;
|
||||
var lockObject = new object();
|
||||
|
||||
// Simulate 10 concurrent consumers trying to process the same message
|
||||
var tasks = Enumerable.Range(1, 10).Select(async consumerId =>
|
||||
{
|
||||
var idempotencyKey = $"concurrent-test:{messageId}";
|
||||
var claimResult = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
$"consumer-{consumerId}",
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
lock (lockObject)
|
||||
{
|
||||
if (claimResult.IsFirstClaim)
|
||||
{
|
||||
processedCount++;
|
||||
_output.WriteLine($"Consumer {consumerId}: Processing message (first claim)");
|
||||
}
|
||||
else
|
||||
{
|
||||
duplicateCount++;
|
||||
_output.WriteLine($"Consumer {consumerId}: Duplicate detected, existing value: {claimResult.ExistingValue}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - Exactly one consumer processed the message
|
||||
processedCount.Should().Be(1, "only one consumer should process the message");
|
||||
duplicateCount.Should().Be(9, "9 consumers should detect duplicate");
|
||||
_output.WriteLine($"Processed: {processedCount}, Duplicates: {duplicateCount}");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_IdempotencyWindowExpires_ReprocessingAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var shortWindow = TimeSpan.FromMilliseconds(200);
|
||||
var idempotencyKey = $"window-test:{messageId}";
|
||||
|
||||
// Act - First claim
|
||||
var firstClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"first-processor",
|
||||
shortWindow);
|
||||
firstClaim.IsFirstClaim.Should().BeTrue();
|
||||
_output.WriteLine("First claim successful");
|
||||
|
||||
// Duplicate should be detected
|
||||
var duplicateClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"duplicate-processor",
|
||||
shortWindow);
|
||||
duplicateClaim.IsDuplicate.Should().BeTrue();
|
||||
_output.WriteLine("Duplicate detected as expected");
|
||||
|
||||
// Wait for window to expire
|
||||
await Task.Delay(500);
|
||||
|
||||
// Act - After expiration, claim should succeed again
|
||||
var afterExpiration = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"new-processor",
|
||||
shortWindow);
|
||||
|
||||
// Assert - Reprocessing allowed after window expiration
|
||||
afterExpiration.IsFirstClaim.Should().BeTrue(
|
||||
"after idempotency window expires, message can be reprocessed");
|
||||
_output.WriteLine("After window expiration, new claim succeeded");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_DifferentMessages_IndependentProcessing()
|
||||
{
|
||||
// Arrange - Three different messages
|
||||
var messageIds = Enumerable.Range(1, 3).Select(_ => Guid.NewGuid()).ToList();
|
||||
var processedIds = new List<Guid>();
|
||||
|
||||
// Act - Process each message (simulating first-time delivery)
|
||||
foreach (var messageId in messageIds)
|
||||
{
|
||||
var idempotencyKey = $"different-msg-test:{messageId}";
|
||||
var claimResult = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
messageId.ToString(),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (claimResult.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - All different messages processed
|
||||
processedIds.Should().BeEquivalentTo(messageIds);
|
||||
_output.WriteLine($"All {messageIds.Count} different messages processed independently");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End At-Least-Once with Idempotency Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EndToEnd_AtLeastOnceWithIdempotency_NoDuplicateProcessing()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.DefaultLeaseDuration = TimeSpan.FromMilliseconds(200);
|
||||
var queue = CreateQueue<TestMessage>(queueOptions);
|
||||
|
||||
var messageId = Guid.NewGuid();
|
||||
var processedIds = new HashSet<Guid>();
|
||||
var deliveryCount = 0;
|
||||
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "E2E test" });
|
||||
|
||||
// Act - Consumer with idempotency-aware processing
|
||||
// Simulate: first delivery - lease but crash, second delivery - process successfully
|
||||
|
||||
// First delivery (crash simulation - don't ack)
|
||||
var firstLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
firstLease.Should().HaveCount(1);
|
||||
deliveryCount++;
|
||||
|
||||
// Attempt to claim for processing
|
||||
var firstClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
$"e2e-test:{firstLease[0].Message.Id}",
|
||||
firstLease[0].MessageId,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (firstClaim.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(firstLease[0].Message.Id);
|
||||
}
|
||||
|
||||
// Simulate crash - don't acknowledge
|
||||
_output.WriteLine("First delivery: Processing started but consumer crashed");
|
||||
|
||||
// Wait for lease expiration
|
||||
await Task.Delay(500);
|
||||
|
||||
// Claim expired message (redelivery)
|
||||
var redelivered = await queue.ClaimExpiredAsync(new ClaimRequest
|
||||
{
|
||||
BatchSize = 1,
|
||||
MinIdleTime = TimeSpan.FromMilliseconds(200),
|
||||
MinDeliveryAttempts = 1
|
||||
});
|
||||
|
||||
if (redelivered.Count > 0)
|
||||
{
|
||||
deliveryCount++;
|
||||
|
||||
// Attempt to claim again (should be duplicate)
|
||||
var secondClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
$"e2e-test:{redelivered[0].Message.Id}",
|
||||
redelivered[0].MessageId,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (secondClaim.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(redelivered[0].Message.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_output.WriteLine($"Second delivery: Duplicate detected, skipping processing");
|
||||
}
|
||||
|
||||
// This time, acknowledge
|
||||
await redelivered[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Second delivery: Message acknowledged");
|
||||
}
|
||||
|
||||
// Assert
|
||||
processedIds.Should().HaveCount(1, "message should be processed exactly once");
|
||||
deliveryCount.Should().BeGreaterThan(1, "message should be delivered at least twice (crash + redelivery)");
|
||||
_output.WriteLine($"Total deliveries: {deliveryCount}, Unique processing: {processedIds.Count}");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EndToEnd_BulkMessages_AtLeastOnceWithIdempotency()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
const int messageCount = 50;
|
||||
var processedIds = new ConcurrentHashSet<Guid>();
|
||||
var deliveryAttempts = new Dictionary<Guid, int>();
|
||||
|
||||
// Send messages
|
||||
var sentIds = new List<Guid>();
|
||||
for (int i = 0; i < messageCount; i++)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
sentIds.Add(id);
|
||||
await queue.EnqueueAsync(new TestMessage { Id = id, Content = $"Bulk-{i}" });
|
||||
}
|
||||
|
||||
// Act - Process all messages with idempotency
|
||||
int remaining = messageCount;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
if (leases.Count == 0) break;
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
var msgId = lease.Message.Id;
|
||||
deliveryAttempts[msgId] = deliveryAttempts.GetValueOrDefault(msgId) + 1;
|
||||
|
||||
// Check idempotency before processing
|
||||
var claim = await _idempotencyStore!.TryClaimAsync(
|
||||
$"bulk-test:{msgId}",
|
||||
lease.MessageId,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (claim.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(msgId);
|
||||
}
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
remaining -= leases.Count;
|
||||
}
|
||||
|
||||
// Assert - All messages processed exactly once
|
||||
processedIds.Count.Should().Be(messageCount, "all messages should be processed");
|
||||
sentIds.Should().BeEquivalentTo(processedIds.ToList(), "all sent messages should be processed");
|
||||
_output.WriteLine($"Processed {processedIds.Count}/{messageCount} messages with idempotency");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_ExtendWindow()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"extend-test:{messageId}";
|
||||
var shortWindow = TimeSpan.FromSeconds(1);
|
||||
|
||||
// Act - Claim with short window
|
||||
var claim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"original-value",
|
||||
shortWindow);
|
||||
claim.IsFirstClaim.Should().BeTrue();
|
||||
|
||||
// Extend the window
|
||||
var extended = await _idempotencyStore!.ExtendAsync(
|
||||
idempotencyKey,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
// Assert - Window extended
|
||||
extended.Should().BeTrue();
|
||||
|
||||
// Duplicate should still be detected after original window would have expired
|
||||
await Task.Delay(1500);
|
||||
var afterOriginalExpiry = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"new-value",
|
||||
shortWindow);
|
||||
|
||||
afterOriginalExpiry.IsDuplicate.Should().BeTrue(
|
||||
"window was extended, so duplicate should still be detected");
|
||||
_output.WriteLine("Window extension verified - duplicate detected after original expiry");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_Release()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"release-test:{messageId}";
|
||||
|
||||
// Claim the key
|
||||
var firstClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"first-value",
|
||||
TimeSpan.FromMinutes(5));
|
||||
firstClaim.IsFirstClaim.Should().BeTrue();
|
||||
|
||||
// Duplicate should be detected
|
||||
var duplicate = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"duplicate-value",
|
||||
TimeSpan.FromMinutes(5));
|
||||
duplicate.IsDuplicate.Should().BeTrue();
|
||||
|
||||
// Act - Release the key
|
||||
var released = await _idempotencyStore!.ReleaseAsync(idempotencyKey);
|
||||
released.Should().BeTrue();
|
||||
|
||||
// Assert - After release, key can be claimed again
|
||||
var afterRelease = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"new-value",
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
afterRelease.IsFirstClaim.Should().BeTrue(
|
||||
"after release, key should be claimable again");
|
||||
_output.WriteLine("Release verified - key claimable after release");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_Exists()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"exists-test:{messageId}";
|
||||
|
||||
// Act - Check before claiming
|
||||
var existsBefore = await _idempotencyStore!.ExistsAsync(idempotencyKey);
|
||||
existsBefore.Should().BeFalse();
|
||||
|
||||
// Claim
|
||||
await _idempotencyStore!.TryClaimAsync(idempotencyKey, "value", TimeSpan.FromMinutes(5));
|
||||
|
||||
// Check after claiming
|
||||
var existsAfter = await _idempotencyStore!.ExistsAsync(idempotencyKey);
|
||||
existsAfter.Should().BeTrue();
|
||||
|
||||
_output.WriteLine("Exists check verified");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_Get()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"get-test:{messageId}";
|
||||
var storedValue = "stored-processor-id";
|
||||
|
||||
// Act - Get before claiming
|
||||
var valueBefore = await _idempotencyStore!.GetAsync(idempotencyKey);
|
||||
valueBefore.Should().BeNull();
|
||||
|
||||
// Claim
|
||||
await _idempotencyStore!.TryClaimAsync(idempotencyKey, storedValue, TimeSpan.FromMinutes(5));
|
||||
|
||||
// Get after claiming
|
||||
var valueAfter = await _idempotencyStore!.GetAsync(idempotencyKey);
|
||||
|
||||
// Assert
|
||||
valueAfter.Should().Be(storedValue);
|
||||
_output.WriteLine($"Get verified - stored value: {valueAfter}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ValkeyMessageQueue<TMessage> CreateQueue<TMessage>(
|
||||
MessageQueueOptions? queueOptions = null)
|
||||
where TMessage : class
|
||||
{
|
||||
queueOptions ??= _fixture.CreateQueueOptions();
|
||||
var transportOptions = _fixture.CreateOptions();
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory!,
|
||||
queueOptions,
|
||||
transportOptions,
|
||||
_fixture.GetLogger<ValkeyMessageQueue<TMessage>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Types
|
||||
|
||||
public sealed class TestMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe hash set for concurrent test scenarios.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentHashSet<T> where T : notnull
|
||||
{
|
||||
private readonly HashSet<T> _set = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public bool Add(T item)
|
||||
{
|
||||
lock (_lock) return _set.Add(item);
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) return _set.Count; }
|
||||
}
|
||||
|
||||
public List<T> ToList()
|
||||
{
|
||||
lock (_lock) return _set.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyContainerFixture.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-004 - Valkey transport compliance tests
|
||||
// Description: Collection fixture providing a shared Valkey container for integration tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Testing.Fixtures;
|
||||
using Testcontainers.Redis;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture that provides a shared Valkey container for integration tests.
|
||||
/// Uses Redis container (Valkey is Redis-compatible).
|
||||
/// Implements IAsyncLifetime to start/stop the container with the test collection.
|
||||
/// </summary>
|
||||
public sealed class ValkeyContainerFixture : RouterCollectionFixture, IAsyncDisposable
|
||||
{
|
||||
private RedisContainer? _container;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Valkey container hostname.
|
||||
/// </summary>
|
||||
public string HostName => _container?.Hostname ?? "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Valkey container mapped port.
|
||||
/// </summary>
|
||||
public int Port => _container?.GetMappedPublicPort(6379) ?? 6379;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the Valkey container.
|
||||
/// </summary>
|
||||
public string ConnectionString => $"{HostName}:{Port}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a null logger for tests.
|
||||
/// </summary>
|
||||
public ILogger<T> GetLogger<T>() => NullLogger<T>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the container is running.
|
||||
/// </summary>
|
||||
public bool IsRunning => _container is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates Valkey transport options configured for the test container.
|
||||
/// </summary>
|
||||
public ValkeyTransportOptions CreateOptions(int? database = null)
|
||||
{
|
||||
return new ValkeyTransportOptions
|
||||
{
|
||||
ConnectionString = ConnectionString,
|
||||
Database = database,
|
||||
InitializationTimeout = TimeSpan.FromSeconds(30),
|
||||
ConnectRetry = 3,
|
||||
AbortOnConnectFail = false,
|
||||
IdempotencyKeyPrefix = "test:idem:"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ValkeyConnectionFactory configured for the test container.
|
||||
/// </summary>
|
||||
public ValkeyConnectionFactory CreateConnectionFactory(int? database = null)
|
||||
{
|
||||
var options = CreateOptions(database);
|
||||
return new ValkeyConnectionFactory(
|
||||
Options.Create(options),
|
||||
GetLogger<ValkeyConnectionFactory>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates message queue options for testing.
|
||||
/// </summary>
|
||||
public StellaOps.Messaging.MessageQueueOptions CreateQueueOptions(
|
||||
string? queueName = null,
|
||||
string? consumerGroup = null,
|
||||
string? consumerName = null)
|
||||
{
|
||||
return new StellaOps.Messaging.MessageQueueOptions
|
||||
{
|
||||
QueueName = queueName ?? $"test:queue:{Guid.NewGuid():N}",
|
||||
ConsumerGroup = consumerGroup ?? "test-group",
|
||||
ConsumerName = consumerName ?? $"consumer-{Environment.ProcessId}",
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(30),
|
||||
MaxDeliveryAttempts = 3,
|
||||
IdempotencyWindow = TimeSpan.FromMinutes(5),
|
||||
ApproximateMaxLength = 10000,
|
||||
RetryInitialBackoff = TimeSpan.FromMilliseconds(100),
|
||||
RetryMaxBackoff = TimeSpan.FromSeconds(10),
|
||||
RetryBackoffMultiplier = 2.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ValkeyMessageQueue for testing.
|
||||
/// </summary>
|
||||
public ValkeyMessageQueue<TMessage> CreateMessageQueue<TMessage>(
|
||||
ValkeyConnectionFactory? connectionFactory = null,
|
||||
StellaOps.Messaging.MessageQueueOptions? queueOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
where TMessage : class
|
||||
{
|
||||
connectionFactory ??= CreateConnectionFactory();
|
||||
queueOptions ??= CreateQueueOptions();
|
||||
var transportOptions = CreateOptions();
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
connectionFactory,
|
||||
queueOptions,
|
||||
transportOptions,
|
||||
GetLogger<ValkeyMessageQueue<TMessage>>(),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the container.
|
||||
/// </summary>
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
if (_container is null)
|
||||
{
|
||||
throw new InvalidOperationException("Valkey container is not running.");
|
||||
}
|
||||
|
||||
await _container.StopAsync();
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new RedisBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures during skip.
|
||||
}
|
||||
|
||||
_container = null;
|
||||
|
||||
throw SkipException.ForSkip(
|
||||
$"Valkey integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task DisposeAsyncCore()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Valkey integration tests.
|
||||
/// All tests in this collection share a single Valkey container.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ValkeyIntegrationTestCollection : ICollectionFixture<ValkeyContainerFixture>
|
||||
{
|
||||
public const string Name = "Valkey Integration Tests";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyIntegrationFactAttribute.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-004 - Valkey transport compliance tests
|
||||
// Description: Attribute that skips Valkey integration tests when Docker is not available
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Fact attribute for Valkey integration tests.
|
||||
/// Skips tests when STELLAOPS_TEST_VALKEY environment variable is not set.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class ValkeyIntegrationFactAttribute : FactAttribute
|
||||
{
|
||||
public ValkeyIntegrationFactAttribute()
|
||||
{
|
||||
var enabled = Environment.GetEnvironmentVariable("STELLAOPS_TEST_VALKEY");
|
||||
if (!string.Equals(enabled, "1", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Skip = "Valkey integration tests are opt-in. Set STELLAOPS_TEST_VALKEY=1 (requires Docker/Testcontainers).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Theory attribute for Valkey integration tests.
|
||||
/// Skips tests when STELLAOPS_TEST_VALKEY environment variable is not set.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class ValkeyIntegrationTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public ValkeyIntegrationTheoryAttribute()
|
||||
{
|
||||
var enabled = Environment.GetEnvironmentVariable("STELLAOPS_TEST_VALKEY");
|
||||
if (!string.Equals(enabled, "1", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Skip = "Valkey integration tests are opt-in. Set STELLAOPS_TEST_VALKEY=1 (requires Docker/Testcontainers).";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.Valkey.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure since not needed for Messaging tests -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Testcontainers.Redis" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,724 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyTransportComplianceTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-004 - Valkey transport compliance tests
|
||||
// Description: Transport compliance tests for Valkey transport covering roundtrip,
|
||||
// pub/sub semantics, consumer groups, ack/nack, and backpressure.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Transport compliance tests for Valkey transport.
|
||||
/// Validates:
|
||||
/// - Message roundtrip (enqueue → lease → message preserved)
|
||||
/// - Consumer group semantics (exclusive delivery, multiple consumers)
|
||||
/// - Ack/Nack behavior (acknowledge, release, dead-letter)
|
||||
/// - Idempotency (duplicate detection)
|
||||
/// - Backpressure (batch limits, pending counts)
|
||||
/// - Lease management (renewal, expiration, claiming)
|
||||
/// </summary>
|
||||
[Collection(ValkeyIntegrationTestCollection.Name)]
|
||||
public sealed class ValkeyTransportComplianceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ValkeyContainerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private ValkeyConnectionFactory? _connectionFactory;
|
||||
|
||||
public ValkeyTransportComplianceTests(ValkeyContainerFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_connectionFactory = _fixture.CreateConnectionFactory();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_connectionFactory is not null)
|
||||
{
|
||||
await _connectionFactory.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region Message Roundtrip Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Roundtrip_SimpleMessage_AllFieldsPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var original = new TestMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = "Hello Valkey!",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Tags = new[] { "tag1", "tag2" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var enqueueResult = await queue.EnqueueAsync(original);
|
||||
enqueueResult.Success.Should().BeTrue();
|
||||
enqueueResult.MessageId.Should().NotBeNullOrEmpty();
|
||||
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases.Should().HaveCount(1);
|
||||
var lease = leases[0];
|
||||
lease.Message.Id.Should().Be(original.Id);
|
||||
lease.Message.Content.Should().Be(original.Content);
|
||||
lease.Message.Tags.Should().BeEquivalentTo(original.Tags);
|
||||
lease.Attempt.Should().Be(1);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
_output.WriteLine("Roundtrip test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Roundtrip_ComplexMessage_PreservedAfterSerialization()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<ComplexMessage>();
|
||||
var original = new ComplexMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Metadata = new Dictionary<string, object>
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42,
|
||||
["key3"] = true
|
||||
},
|
||||
NestedData = new NestedObject
|
||||
{
|
||||
Name = "nested",
|
||||
Value = 123.45m
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(original);
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
var lease = leases[0];
|
||||
lease.Message.Id.Should().Be(original.Id);
|
||||
lease.Message.NestedData.Should().NotBeNull();
|
||||
lease.Message.NestedData!.Name.Should().Be(original.NestedData!.Name);
|
||||
lease.Message.NestedData.Value.Should().Be(original.NestedData.Value);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
_output.WriteLine("Complex message roundtrip test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Roundtrip_BinaryData_PreservesAllBytes()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<BinaryMessage>();
|
||||
var binaryPayload = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray();
|
||||
var original = new BinaryMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Data = binaryPayload
|
||||
};
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(original);
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases[0].Message.Data.Should().BeEquivalentTo(binaryPayload);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Binary data roundtrip test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(10)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Roundtrip_MultipleMessages_OrderPreserved(int messageCount)
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var messages = Enumerable.Range(1, messageCount)
|
||||
.Select(i => new TestMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = $"Message-{i:D5}",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMilliseconds(i)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Act - Enqueue all
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
await queue.EnqueueAsync(msg);
|
||||
}
|
||||
|
||||
// Lease and verify order
|
||||
var receivedContents = new List<string>();
|
||||
int remaining = messageCount;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var batchSize = Math.Min(remaining, 50);
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = batchSize });
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
receivedContents.Add(lease.Message.Content!);
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
remaining -= leases.Count;
|
||||
}
|
||||
|
||||
// Assert - FIFO order preserved
|
||||
var expectedContents = messages.Select(m => m.Content).ToList();
|
||||
receivedContents.Should().BeEquivalentTo(expectedContents, options => options.WithStrictOrdering());
|
||||
_output.WriteLine($"Order preserved for {messageCount} messages");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consumer Group Semantics Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerGroup_MultipleConsumers_ExclusiveDelivery()
|
||||
{
|
||||
// Arrange - Two consumers in same group
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
var queue1 = CreateQueue<TestMessage>(queueOptions: queueOptions, consumerName: "consumer-1");
|
||||
var queue2 = CreateQueue<TestMessage>(queueOptions: queueOptions, consumerName: "consumer-2");
|
||||
|
||||
var messages = Enumerable.Range(1, 20)
|
||||
.Select(i => new TestMessage { Id = Guid.NewGuid(), Content = $"Msg-{i}" })
|
||||
.ToList();
|
||||
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
await queue1.EnqueueAsync(msg);
|
||||
}
|
||||
|
||||
// Act - Both consumers lease
|
||||
var leases1 = await queue1.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
var leases2 = await queue2.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
|
||||
// Assert - Messages should be distributed (no duplicates)
|
||||
var allIds = leases1.Concat(leases2).Select(l => l.Message.Id).ToList();
|
||||
allIds.Should().OnlyHaveUniqueItems("each message should be delivered to only one consumer");
|
||||
allIds.Should().HaveCount(20, "all messages should be delivered");
|
||||
|
||||
// Cleanup
|
||||
foreach (var lease in leases1.Concat(leases2))
|
||||
{
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
_output.WriteLine("Exclusive delivery test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerGroup_DifferentGroups_EachReceivesAllMessages()
|
||||
{
|
||||
// Arrange - Two different consumer groups
|
||||
var queueName = $"test:queue:{Guid.NewGuid():N}";
|
||||
var options1 = _fixture.CreateQueueOptions(queueName: queueName, consumerGroup: "group-1");
|
||||
var options2 = _fixture.CreateQueueOptions(queueName: queueName, consumerGroup: "group-2");
|
||||
|
||||
var queue1 = CreateQueue<TestMessage>(queueOptions: options1);
|
||||
var queue2 = CreateQueue<TestMessage>(queueOptions: options2);
|
||||
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Shared message" };
|
||||
|
||||
// Act - Enqueue to one queue (same stream)
|
||||
await queue1.EnqueueAsync(message);
|
||||
|
||||
// Both groups should receive the message
|
||||
var leases1 = await queue1.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
var leases2 = await queue2.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases1.Should().HaveCount(1);
|
||||
leases2.Should().HaveCount(1);
|
||||
leases1[0].Message.Id.Should().Be(message.Id);
|
||||
leases2[0].Message.Id.Should().Be(message.Id);
|
||||
|
||||
await leases1[0].AcknowledgeAsync();
|
||||
await leases2[0].AcknowledgeAsync();
|
||||
|
||||
_output.WriteLine("Different groups test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ack/Nack/Release Semantics Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Acknowledge_RemovesMessageFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = "Ack test" });
|
||||
|
||||
// Act
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
await leases[0].AcknowledgeAsync();
|
||||
|
||||
// Assert - No more messages
|
||||
var pending = await queue.GetPendingCountAsync();
|
||||
pending.Should().Be(0);
|
||||
|
||||
var moreLeases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
moreLeases.Should().BeEmpty();
|
||||
|
||||
_output.WriteLine("Acknowledge removes message test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Release_Retry_MessageBecomesAvailableAgain()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.RetryInitialBackoff = TimeSpan.Zero; // No backoff for test speed
|
||||
var queue = CreateQueue<TestMessage>(queueOptions: queueOptions);
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Retry test" };
|
||||
await queue.EnqueueAsync(message);
|
||||
|
||||
// Act - Lease and release for retry
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
leases.Should().HaveCount(1);
|
||||
leases[0].Attempt.Should().Be(1);
|
||||
await leases[0].ReleaseAsync(ReleaseDisposition.Retry);
|
||||
|
||||
// Wait briefly for re-enqueue
|
||||
await Task.Delay(100);
|
||||
|
||||
// Lease again
|
||||
var retryLeases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
retryLeases.Should().HaveCount(1);
|
||||
retryLeases[0].Message.Id.Should().Be(message.Id);
|
||||
retryLeases[0].Attempt.Should().Be(2);
|
||||
|
||||
await retryLeases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Release retry test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task DeadLetter_MovesMessageToDeadLetterQueue()
|
||||
{
|
||||
// Arrange
|
||||
var mainQueueName = $"test:main:{Guid.NewGuid():N}";
|
||||
var dlqName = $"test:dlq:{Guid.NewGuid():N}";
|
||||
|
||||
var mainOptions = _fixture.CreateQueueOptions(queueName: mainQueueName);
|
||||
mainOptions.DeadLetterQueue = dlqName;
|
||||
|
||||
var dlqOptions = _fixture.CreateQueueOptions(queueName: dlqName);
|
||||
|
||||
var mainQueue = CreateQueue<TestMessage>(queueOptions: mainOptions);
|
||||
var dlqQueue = CreateQueue<TestMessage>(queueOptions: dlqOptions);
|
||||
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "DLQ test" };
|
||||
await mainQueue.EnqueueAsync(message);
|
||||
|
||||
// Act
|
||||
var leases = await mainQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
await leases[0].DeadLetterAsync("test-reason");
|
||||
|
||||
// Assert - Message should be in DLQ
|
||||
var dlqLeases = await dlqQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
dlqLeases.Should().HaveCount(1);
|
||||
dlqLeases[0].Message.Id.Should().Be(message.Id);
|
||||
|
||||
// Main queue should be empty
|
||||
var mainPending = await mainQueue.GetPendingCountAsync();
|
||||
mainPending.Should().Be(0);
|
||||
|
||||
await dlqLeases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Dead letter test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task MaxDeliveryAttempts_ExceededCausesDeadLetter()
|
||||
{
|
||||
// Arrange
|
||||
var mainQueueName = $"test:main:{Guid.NewGuid():N}";
|
||||
var dlqName = $"test:dlq:{Guid.NewGuid():N}";
|
||||
|
||||
var mainOptions = _fixture.CreateQueueOptions(queueName: mainQueueName);
|
||||
mainOptions.MaxDeliveryAttempts = 3;
|
||||
mainOptions.DeadLetterQueue = dlqName;
|
||||
mainOptions.RetryInitialBackoff = TimeSpan.Zero;
|
||||
|
||||
var dlqOptions = _fixture.CreateQueueOptions(queueName: dlqName);
|
||||
|
||||
var mainQueue = CreateQueue<TestMessage>(queueOptions: mainOptions);
|
||||
var dlqQueue = CreateQueue<TestMessage>(queueOptions: dlqOptions);
|
||||
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Max attempts test" };
|
||||
await mainQueue.EnqueueAsync(message);
|
||||
|
||||
// Act - Retry until max attempts exceeded
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var leases = await mainQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
if (leases.Count == 0) break;
|
||||
await leases[0].ReleaseAsync(ReleaseDisposition.Retry);
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
// Wait for final retry to dead-letter
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert - Message should be in DLQ
|
||||
var dlqLeases = await dlqQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
dlqLeases.Should().HaveCount(1);
|
||||
dlqLeases[0].Message.Id.Should().Be(message.Id);
|
||||
|
||||
await dlqLeases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Max delivery attempts test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Idempotency Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Idempotency_DuplicateKey_ReturnsExistingMessage()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var idempotencyKey = Guid.NewGuid().ToString();
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Idempotent message" };
|
||||
|
||||
// Act - Enqueue twice with same key
|
||||
var result1 = await queue.EnqueueAsync(message, EnqueueOptions.WithIdempotencyKey(idempotencyKey));
|
||||
var result2 = await queue.EnqueueAsync(message, EnqueueOptions.WithIdempotencyKey(idempotencyKey));
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().BeTrue();
|
||||
result1.WasDuplicate.Should().BeFalse();
|
||||
|
||||
result2.Success.Should().BeTrue();
|
||||
result2.WasDuplicate.Should().BeTrue();
|
||||
result2.MessageId.Should().Be(result1.MessageId);
|
||||
|
||||
// Only one message should be in queue
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
leases.Should().HaveCount(1);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Idempotency test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Idempotency_DifferentKeys_BothMessagesEnqueued()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var message1 = new TestMessage { Id = Guid.NewGuid(), Content = "Message 1" };
|
||||
var message2 = new TestMessage { Id = Guid.NewGuid(), Content = "Message 2" };
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(message1, EnqueueOptions.WithIdempotencyKey("key-1"));
|
||||
await queue.EnqueueAsync(message2, EnqueueOptions.WithIdempotencyKey("key-2"));
|
||||
|
||||
// Assert
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
leases.Should().HaveCount(2);
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
_output.WriteLine("Different idempotency keys test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Backpressure Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Backpressure_BatchSize_LimitsMessageCount()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = $"Msg-{i}" });
|
||||
}
|
||||
|
||||
// Act - Request only 10
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
|
||||
// Assert
|
||||
leases.Should().HaveCount(10);
|
||||
|
||||
// Cleanup
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
// Remaining messages
|
||||
var pending = await queue.GetPendingCountAsync();
|
||||
pending.Should().Be(0); // Not pending because not leased yet
|
||||
|
||||
_output.WriteLine("Batch size backpressure test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Backpressure_PendingCount_ReflectsUnacknowledged()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = $"Msg-{i}" });
|
||||
}
|
||||
|
||||
// Act - Lease 30, ack 10
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 30 });
|
||||
leases.Should().HaveCount(30);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await leases[i].AcknowledgeAsync();
|
||||
}
|
||||
|
||||
// Assert - 20 still pending
|
||||
var pending = await queue.GetPendingCountAsync();
|
||||
pending.Should().Be(20);
|
||||
|
||||
// Cleanup
|
||||
for (int i = 10; i < 30; i++)
|
||||
{
|
||||
await leases[i].AcknowledgeAsync();
|
||||
}
|
||||
|
||||
_output.WriteLine("Pending count test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Backpressure_EmptyQueue_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
|
||||
// Act
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
|
||||
// Assert
|
||||
leases.Should().BeEmpty();
|
||||
_output.WriteLine("Empty queue test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lease Management Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task LeaseRenewal_ExtendsLeaseTime()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = "Renewal test" });
|
||||
|
||||
// Act
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest
|
||||
{
|
||||
BatchSize = 1,
|
||||
LeaseDuration = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
var originalExpiry = leases[0].LeaseExpiresAt;
|
||||
|
||||
await leases[0].RenewAsync(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Assert - Lease should be extended
|
||||
leases[0].LeaseExpiresAt.Should().BeAfter(originalExpiry);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Lease renewal test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ClaimExpired_RecoversStaleMessages()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.DefaultLeaseDuration = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
var queue = CreateQueue<TestMessage>(queueOptions: queueOptions);
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = "Stale test" });
|
||||
|
||||
// Lease and let expire
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
leases.Should().HaveCount(1);
|
||||
|
||||
// Wait for lease to expire
|
||||
await Task.Delay(500);
|
||||
|
||||
// Act - Claim expired
|
||||
var claimed = await queue.ClaimExpiredAsync(new ClaimRequest
|
||||
{
|
||||
BatchSize = 10,
|
||||
MinIdleTime = TimeSpan.FromMilliseconds(100),
|
||||
MinDeliveryAttempts = 1
|
||||
});
|
||||
|
||||
// Assert
|
||||
claimed.Should().HaveCount(1);
|
||||
claimed[0].Message.Content.Should().Be("Stale test");
|
||||
claimed[0].Attempt.Should().BeGreaterThan(1);
|
||||
|
||||
await claimed[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Claim expired test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metadata/Headers Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Metadata_CorrelationId_PreservedInLease()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Correlation test" };
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(message, EnqueueOptions.WithCorrelation(correlationId));
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases[0].CorrelationId.Should().Be(correlationId);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Correlation ID test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Metadata_TenantId_PreservedInLease()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var tenantId = "tenant-123";
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Tenant test" };
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(message, new EnqueueOptions { TenantId = tenantId });
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases[0].TenantId.Should().Be(tenantId);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Tenant ID test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Resilience Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConnectionResilience_Ping_Succeeds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var act = async () => await _connectionFactory!.PingAsync();
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync();
|
||||
_output.WriteLine("Ping test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConnectionResilience_QueueProviderName_IsValkey()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
|
||||
// Assert
|
||||
queue.ProviderName.Should().Be("valkey");
|
||||
queue.QueueName.Should().NotBeNullOrEmpty();
|
||||
|
||||
_output.WriteLine("Provider name test passed");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ValkeyMessageQueue<TMessage> CreateQueue<TMessage>(
|
||||
MessageQueueOptions? queueOptions = null,
|
||||
string? consumerName = null)
|
||||
where TMessage : class
|
||||
{
|
||||
queueOptions ??= _fixture.CreateQueueOptions();
|
||||
if (consumerName is not null)
|
||||
{
|
||||
queueOptions.ConsumerName = consumerName;
|
||||
}
|
||||
|
||||
var transportOptions = _fixture.CreateOptions();
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory!,
|
||||
queueOptions,
|
||||
transportOptions,
|
||||
_fixture.GetLogger<ValkeyMessageQueue<TMessage>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Message Types
|
||||
|
||||
public sealed class TestMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public string[]? Tags { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ComplexMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
public NestedObject? NestedData { get; set; }
|
||||
}
|
||||
|
||||
public sealed class NestedObject
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public decimal Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BinaryMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public byte[]? Data { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user