diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 3715180..3e7be40 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -90,6 +90,10 @@ exclude-labels: - 'internal' template: | + ## Summary + + SUMMARY_GOES_HERE + ## Changes $CHANGES diff --git a/Directory.Packages.props b/Directory.Packages.props index 39689db..961b03e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,13 +44,13 @@ - - + + + - diff --git a/test/DynamoDb.DistributedLock.Tests/DistributedLockHandleTests.cs b/test/DynamoDb.DistributedLock.Tests/DistributedLockHandleTests.cs index 454c962..b5cee7e 100644 --- a/test/DynamoDb.DistributedLock.Tests/DistributedLockHandleTests.cs +++ b/test/DynamoDb.DistributedLock.Tests/DistributedLockHandleTests.cs @@ -1,15 +1,14 @@ -using AutoFixture.Xunit3; -using DynamoDb.DistributedLock.Tests.TestKit.Attributes; using AwesomeAssertions; -using NSubstitute; -using NSubstitute.ExceptionExtensions; +using Compono; +using Compono.XunitV3; +using DynamoDb.DistributedLock.Tests.TestKit.Profiles; namespace DynamoDb.DistributedLock.Tests; public class DistributedLockHandleTests { [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenLockServiceIsNull_ShouldThrowArgumentNullException( string resourceId, string ownerId, @@ -22,7 +21,7 @@ public void Constructor_WhenLockServiceIsNull_ShouldThrowArgumentNullException( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenResourceIdIsNull_ShouldThrowArgumentNullException( IDynamoDbDistributedLock lockService, string ownerId, @@ -35,7 +34,7 @@ public void Constructor_WhenResourceIdIsNull_ShouldThrowArgumentNullException( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenOwnerIdIsNull_ShouldThrowArgumentNullException( IDynamoDbDistributedLock lockService, string resourceId, @@ -48,7 +47,7 @@ public void Constructor_WhenOwnerIdIsNull_ShouldThrowArgumentNullException( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Properties_ShouldReturnConstructorValues( IDynamoDbDistributedLock lockService, string resourceId, @@ -63,7 +62,7 @@ public void Properties_ShouldReturnConstructorValues( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void IsAcquired_WhenNotDisposedAndNotExpired_ShouldReturnTrue( IDynamoDbDistributedLock lockService, string resourceId, @@ -76,7 +75,7 @@ public void IsAcquired_WhenNotDisposedAndNotExpired_ShouldReturnTrue( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void IsAcquired_WhenExpired_ShouldReturnFalse( IDynamoDbDistributedLock lockService, string resourceId, @@ -89,7 +88,7 @@ public void IsAcquired_WhenExpired_ShouldReturnFalse( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task IsAcquired_WhenDisposed_ShouldReturnFalse( IDynamoDbDistributedLock lockService, string resourceId, @@ -104,9 +103,9 @@ public async Task IsAcquired_WhenDisposed_ShouldReturnFalse( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task DisposeAsync_ShouldCallReleaseLockAsync( - [Frozen] IDynamoDbDistributedLock lockService, + IDynamoDbDistributedLock lockService, string resourceId, string ownerId, DateTimeOffset expiresAt) @@ -115,13 +114,15 @@ public async Task DisposeAsync_ShouldCallReleaseLockAsync( await handle.DisposeAsync(); - await lockService.Received(1).ReleaseLockAsync(resourceId, ownerId, Arg.Any()); + lockService.Verify() + .ReleaseLockAsync(resourceId, ownerId, Match.Any()) + .Once(); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task DisposeAsync_WhenCalledMultipleTimes_ShouldOnlyCallReleaseLockOnce( - [Frozen] IDynamoDbDistributedLock lockService, + IDynamoDbDistributedLock lockService, string resourceId, string ownerId, DateTimeOffset expiresAt) @@ -132,19 +133,22 @@ public async Task DisposeAsync_WhenCalledMultipleTimes_ShouldOnlyCallReleaseLock await handle.DisposeAsync(); await handle.DisposeAsync(); - await lockService.Received(1).ReleaseLockAsync(resourceId, ownerId, Arg.Any()); + lockService.Verify() + .ReleaseLockAsync(resourceId, ownerId, Match.Any()) + .Once(); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task DisposeAsync_WhenReleaseLockThrows_ShouldSwallowException( - [Frozen] IDynamoDbDistributedLock lockService, + IDynamoDbDistributedLock lockService, string resourceId, string ownerId, DateTimeOffset expiresAt) { - lockService.ReleaseLockAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .ThrowsAsync(new InvalidOperationException("Test exception")); + lockService.Configure() + .ReleaseLockAsync(Match.Any(), Match.Any(), Match.Any()) + .Throws(new InvalidOperationException("Test exception")); var handle = new DistributedLockHandle(lockService, resourceId, ownerId, expiresAt); @@ -152,4 +156,4 @@ public async Task DisposeAsync_WhenReleaseLockThrows_ShouldSwallowException( await act.Should().NotThrowAsync(); } -} \ No newline at end of file +} diff --git a/test/DynamoDb.DistributedLock.Tests/DynamoDb.DistributedLock.Tests.csproj b/test/DynamoDb.DistributedLock.Tests/DynamoDb.DistributedLock.Tests.csproj index 0e3b5ad..2351349 100644 --- a/test/DynamoDb.DistributedLock.Tests/DynamoDb.DistributedLock.Tests.csproj +++ b/test/DynamoDb.DistributedLock.Tests/DynamoDb.DistributedLock.Tests.csproj @@ -10,6 +10,7 @@ true true false + true true @@ -24,14 +25,14 @@ - - + + + - all diff --git a/test/DynamoDb.DistributedLock.Tests/DynamoDbDistributedLockTests.cs b/test/DynamoDb.DistributedLock.Tests/DynamoDbDistributedLockTests.cs index c06541b..740df1f 100644 --- a/test/DynamoDb.DistributedLock.Tests/DynamoDbDistributedLockTests.cs +++ b/test/DynamoDb.DistributedLock.Tests/DynamoDbDistributedLockTests.cs @@ -1,22 +1,22 @@ +using System.Diagnostics.Metrics; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; -using AutoFixture.Xunit3; -using DynamoDb.DistributedLock.Tests.TestKit.Attributes; using AwesomeAssertions; +using Compono; +using Compono.XunitV3; using DynamoDb.DistributedLock.Metrics; using DynamoDb.DistributedLock.Tests.Metrics; -using DynamoDb.DistributedLock.Tests.TestKit.Extensions; +using DynamoDb.DistributedLock.Tests.TestKit.Profiles; using Microsoft.Extensions.Options; -using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace DynamoDb.DistributedLock.Tests; public class DynamoDbDistributedLockTests { [Theory] - [DynamoDbDistributedLockAutoData] - public void Constructor_WhenClientIsNull_ShouldThrowArgumentNullException(IOptions options, + [Compose] + public void Constructor_WhenClientIsNull_ShouldThrowArgumentNullException( + IOptions options, ILockMetrics lockMetrics) { Action act = () => _ = new DynamoDbDistributedLock(null!, options, lockMetrics); @@ -26,7 +26,7 @@ public void Constructor_WhenClientIsNull_ShouldThrowArgumentNullException(IOptio } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenOptionsValueIsNull_ShouldThrowArgumentNullException( IAmazonDynamoDB client, IOptions nullOptions, @@ -37,9 +37,9 @@ public void Constructor_WhenOptionsValueIsNull_ShouldThrowArgumentNullException( act.Should().Throw() .Which.ParamName.Should().Be("options"); } - + [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenLockMetricsValueIsNull_ShouldThrowArgumentNullException( IAmazonDynamoDB client, IOptions options) @@ -51,17 +51,19 @@ public void Constructor_WhenLockMetricsValueIsNull_ShouldThrowArgumentNullExcept } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenLockIsAvailable_ShouldReturnTrue( - [Frozen] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, + [Shared] Meter meter, [Shared] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, DynamoDbDistributedLock sut, string resourceId, string ownerId) { - // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(new PutItemResponse()); + // Arrange - no argument matching needed (a blanket response regardless of args); a literal + // discriminator argument just selects the (PutItemRequest, CancellationToken) overload. + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Returns(Task.FromResult(new PutItemResponse())); // Act - var result = await sut.AcquireLockAsync(resourceId, ownerId, CancellationToken.None); + var result = await sut.AcquireLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert result.Should().BeTrue(); @@ -70,17 +72,18 @@ public async Task AcquireLockAsync_WhenLockIsAvailable_ShouldReturnTrue( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenLockAlreadyExists_ShouldReturnFalse( - [Frozen] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, + [Shared] Meter meter, [Shared] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, DynamoDbDistributedLock sut, string resourceId, string ownerId) { // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ConditionalCheckFailedException("lock exists")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new ConditionalCheckFailedException("lock exists")); // Act - var result = await sut.AcquireLockAsync(resourceId, ownerId, CancellationToken.None); + var result = await sut.AcquireLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert result.Should().BeFalse(); @@ -89,17 +92,18 @@ public async Task AcquireLockAsync_WhenLockAlreadyExists_ShouldReturnFalse( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenUnexpectedExceptionOccurs_ShouldThrow( - [Frozen] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, + [Shared] Meter meter, [Shared] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, DynamoDbDistributedLock sut, string resourceId, string ownerId) { // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new InvalidOperationException("unexpected failure")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new InvalidOperationException("unexpected failure")); // Act - var act = async () => await sut.AcquireLockAsync(resourceId, ownerId, CancellationToken.None); + var act = async () => await sut.AcquireLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert await act.Should().ThrowAsync(); @@ -108,20 +112,22 @@ public async Task AcquireLockAsync_WhenUnexpectedExceptionOccurs_ShouldThrow( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ReleaseLockAsync_WhenOwnerMatches_ShouldReturnTrue( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.DeleteItemAsync(Arg.Any(), Arg.Any()) - .Returns(new DeleteItemResponse()); + dynamo.Configure() + .DeleteItemAsync(new DeleteItemRequest(), CancellationToken.None) + .Returns(Task.FromResult(new DeleteItemResponse())); // Act - var result = await sut.ReleaseLockAsync(resourceId, ownerId, CancellationToken.None); + var result = await sut.ReleaseLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert result.Should().BeTrue(); @@ -130,20 +136,22 @@ public async Task ReleaseLockAsync_WhenOwnerMatches_ShouldReturnTrue( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ReleaseLockAsync_WhenOwnerDoesNotMatch_ShouldReturnFalse( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.DeleteItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ConditionalCheckFailedException("owner mismatch")); + dynamo.Configure() + .DeleteItemAsync(new DeleteItemRequest(), CancellationToken.None) + .Throws(new ConditionalCheckFailedException("owner mismatch")); // Act - var result = await sut.ReleaseLockAsync(resourceId, ownerId, CancellationToken.None); + var result = await sut.ReleaseLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert result.Should().BeFalse(); @@ -152,20 +160,22 @@ public async Task ReleaseLockAsync_WhenOwnerDoesNotMatch_ShouldReturnFalse( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ReleaseLockAsync_WhenUnexpectedExceptionOccurs_ShouldThrow( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.DeleteItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new InvalidOperationException("unexpected failure")); + dynamo.Configure() + .DeleteItemAsync(new DeleteItemRequest(), CancellationToken.None) + .Throws(new InvalidOperationException("unexpected failure")); // Act - var act = async () => await sut.ReleaseLockAsync(resourceId, ownerId, CancellationToken.None); + var act = async () => await sut.ReleaseLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert await act.Should().ThrowAsync(); @@ -174,20 +184,22 @@ public async Task ReleaseLockAsync_WhenUnexpectedExceptionOccurs_ShouldThrow( } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockHandleAsync_WhenLockIsAvailable_ShouldReturnHandle( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(new PutItemResponse()); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Returns(Task.FromResult(new PutItemResponse())); // Act - var result = await sut.AcquireLockHandleAsync(resourceId, ownerId, CancellationToken.None); + var result = await sut.AcquireLockHandleAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert result.Should().NotBeNull(); @@ -195,123 +207,163 @@ public async Task AcquireLockHandleAsync_WhenLockIsAvailable_ShouldReturnHandle( result.OwnerId.Should().Be(ownerId); result.IsAcquired.Should().BeTrue(); result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); - + metricAggregator.Collect(MetricNames.LockAcquire).Should().HaveCount(1); metricAggregator.Collect(MetricNames.LockAcquireFailed).Should().HaveCount(0); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockHandleAsync_WhenLockAlreadyExists_ShouldReturnNull( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ConditionalCheckFailedException("lock exists")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new ConditionalCheckFailedException("lock exists")); // Act - var result = await sut.AcquireLockHandleAsync(resourceId, ownerId, CancellationToken.None); + var result = await sut.AcquireLockHandleAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert result.Should().BeNull(); - + metricAggregator.Collect(MetricNames.LockAcquire).Should().HaveCount(0); metricAggregator.Collect(MetricNames.LockAcquireFailed).Should().HaveCount(1); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockHandleAsync_WhenUnexpectedExceptionOccurs_ShouldThrow( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new InvalidOperationException("unexpected failure")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new InvalidOperationException("unexpected failure")); // Act - var act = async () => await sut.AcquireLockHandleAsync(resourceId, ownerId, CancellationToken.None); + var act = async () => await sut.AcquireLockHandleAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert await act.Should().ThrowAsync(); - + metricAggregator.Collect(MetricNames.LockAcquire).Should().HaveCount(0); metricAggregator.Collect(MetricNames.LockAcquireFailed).Should().HaveCount(1); } + // ADR-0044 Amendment 21 (overload-safe argument matching): asserts on + // DeleteItemRequest.ConditionExpression/ExpressionAttributeValues *content* via the new + // DeleteItemAsyncMatching(...) surface, which shares DeleteItemAsync's own entries/call log - + // the discriminator-only Configure() below still answers every real call regardless of content; + // Verify() below independently filters by the predicate. [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockHandleAsync_DisposeHandle_ShouldCallReleaseLock( - [Frozen] IAmazonDynamoDB dynamo, + [Shared] Meter meter, + [Shared] IAmazonDynamoDB dynamo, DynamoDbDistributedLock sut, TestMetricAggregator metricAggregator, string resourceId, string ownerId) { // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(new PutItemResponse()); - dynamo.DeleteItemAsync(Arg.Any(), Arg.Any()) - .Returns(new DeleteItemResponse()); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Returns(Task.FromResult(new PutItemResponse())); + dynamo.Configure() + .DeleteItemAsync(new DeleteItemRequest(), CancellationToken.None) + .Returns(Task.FromResult(new DeleteItemResponse())); // Act - var handle = await sut.AcquireLockHandleAsync(resourceId, ownerId, CancellationToken.None); + var handle = await sut.AcquireLockHandleAsync(resourceId, ownerId, TestContext.Current.CancellationToken); await handle!.DisposeAsync(); // Assert - await dynamo.Received(1).DeleteItemAsync( - Arg.Is(req => - req.ConditionExpression.Contains("ownerId = :owner") && - req.ExpressionAttributeValues.ContainsKey(":owner") && - req.ExpressionAttributeValues[":owner"].S == ownerId), - Arg.Any()); - + dynamo.Verify() + .DeleteItemAsyncMatching( + Match.Is(req => + req.ConditionExpression.Contains("ownerId = :owner") && + req.ExpressionAttributeValues.ContainsKey(":owner") && + req.ExpressionAttributeValues[":owner"].S == ownerId), + Match.Any()) + .Once(); + metricAggregator.Collect(MetricNames.LockAcquire).Should().HaveCount(1); metricAggregator.Collect(MetricNames.LockAcquireFailed).Should().HaveCount(0); } - + [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenLockIsAvailable_TimersRecordMetrics( - [Frozen] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, + [Shared] Meter meter, [Shared] IAmazonDynamoDB dynamo, TestMetricAggregator metricAggregator, DynamoDbDistributedLock sut, string resourceId, string ownerId) { - // Arrange - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(async _ => - { - // simulate some delay to ensure timer captures it - await Task.Delay(TimeSpan.FromMilliseconds(5)); - return new PutItemResponse(); - }); - - dynamo.DeleteItemAsync(Arg.Any(), Arg.Any()) - .Returns(async _ => - { - // simulate some delay to ensure timer captures it - await Task.Delay(TimeSpan.FromMilliseconds(5)); - return new DeleteItemResponse(); - }); - - // Act - var acquired = await sut.AcquireLockAsync(resourceId, ownerId, CancellationToken.None); - var released = await sut.ReleaseLockAsync(resourceId, ownerId, CancellationToken.None); + // No argument matching or per-call sequencing needed here - a literal discriminator argument + // selects the (PutItemRequest, CancellationToken)/(DeleteItemRequest, CancellationToken) + // overload. Each Configure() call is deferred until immediately before the SUT call it backs - + // unlike the retry-loop tests, these are two separate, test-controlled SUT operations, so + // reconfiguring between them is enough for the RIGHT response to be in play for each call. + // + // Codex review (LayeredCraft/dynamodb-distributed-lock#76): Compono.TestDoubles has no + // invocation-aware callback - `DelayedPutItemResponseAsync()`/`DelayedDeleteItemResponseAsync()` + // are eagerly invoked (and their own Task.Delay starts counting) at Configure() time, one + // statement BEFORE the SUT actually awaits them, not when the SUT invokes the double. Any + // scheduling/composition overhead between that Configure() call and the SUT's own internal + // stopwatch starting eats directly into the delay budget, which a tight ~5ms delay against a + // ">4" threshold has essentially no margin to absorb - a real, observed CI flake (2.21ms + // measured, not a lock-acquisition correctness bug). Compono.NSubstitute's invocation-aware + // `Returns(callInfo => ...)` would eliminate the race entirely, but reintroducing it here + // would partially undo the very NSubstitute-removal this migration is about. Widening the + // delay/threshold margin instead: even several milliseconds of Arrange-to-await overhead can't + // push the measured duration below a threshold this far under the configured delay. + + // Arrange + Act (acquire) + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Returns(DelayedPutItemResponseAsync()); + var acquired = await sut.AcquireLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); + + // Arrange + Act (release) + dynamo.Configure() + .DeleteItemAsync(new DeleteItemRequest(), CancellationToken.None) + .Returns(DelayedDeleteItemResponseAsync()); + var released = await sut.ReleaseLockAsync(resourceId, ownerId, TestContext.Current.CancellationToken); // Assert acquired.Should().BeTrue(); released.Should().BeTrue(); - + var acquisitionTimer = metricAggregator.Collect(MetricNames.LockAcquireTimer).Single(); - acquisitionTimer.Value.Should().BeGreaterThan(4); - + acquisitionTimer.Value.Should().BeGreaterThan(20); + var releaseTimer = metricAggregator.Collect(MetricNames.LockReleaseTimer).Single(); - releaseTimer.Value.Should().BeGreaterThan(4); + releaseTimer.Value.Should().BeGreaterThan(20); + } + + private static async Task DelayedPutItemResponseAsync() + { + // simulate some delay to ensure the timer above captures it - see the caller's own comment + // for why this needs a generous margin over the ">20" assertion threshold. + await Task.Delay(TimeSpan.FromMilliseconds(100)); + return new PutItemResponse(); + } + + private static async Task DelayedDeleteItemResponseAsync() + { + // simulate some delay to ensure the timer above captures it - see the caller's own comment + // for why this needs a generous margin over the ">20" assertion threshold. + await Task.Delay(TimeSpan.FromMilliseconds(100)); + return new DeleteItemResponse(); } -} \ No newline at end of file +} diff --git a/test/DynamoDb.DistributedLock.Tests/Extensions/ServiceCollectionExtensionsTests.cs b/test/DynamoDb.DistributedLock.Tests/Extensions/ServiceCollectionExtensionsTests.cs index d7e3535..83104df 100644 --- a/test/DynamoDb.DistributedLock.Tests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/DynamoDb.DistributedLock.Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -1,18 +1,23 @@ using Amazon.DynamoDBv2; using Amazon.Extensions.NETCore.Setup; using Amazon.Runtime; -using DynamoDb.DistributedLock.Extensions; -using DynamoDb.DistributedLock.Tests.TestKit.Attributes; using AwesomeAssertions; +using Compono; +using Compono.XunitV3; +using DynamoDb.DistributedLock.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using NSubstitute; namespace DynamoDb.DistributedLock.Tests.Extensions; public class ServiceCollectionExtensionsTests { + // This only needs a working IAmazonDynamoDB instance to bypass credential resolution - no + // Configure()/Verify() call needed here. + private static IAmazonDynamoDB CreateDynamoDbDouble() => + Composer.Create(builder => builder.UseGeneratedTestDoubles()).Create(); + [Fact] public void AddDynamoDbDistributedLock_WithAction_SetsUpServiceAndOptions() { @@ -26,9 +31,9 @@ public void AddDynamoDbDistributedLock_WithAction_SetsUpServiceAndOptions() options.LockTimeoutSeconds = 45; }); - // 👇 Override with mock AFTER to bypass credential resolution - services.AddSingleton(Substitute.For()); - + // 👇 Override with a double AFTER to bypass credential resolution + services.AddSingleton(CreateDynamoDbDouble()); + var provider = services.BuildServiceProvider(); // Assert @@ -60,8 +65,8 @@ public void AddDynamoDbDistributedLock_WithConfiguration_BindsOptionsCorrectly() // Act services.AddDynamoDbDistributedLock(configuration); - // 👇 Override with mock AFTER to bypass credential resolution - services.AddSingleton(Substitute.For()); + // 👇 Override with a double AFTER to bypass credential resolution + services.AddSingleton(CreateDynamoDbDouble()); var provider = services.BuildServiceProvider(); // Assert @@ -74,8 +79,9 @@ public void AddDynamoDbDistributedLock_WithConfiguration_BindsOptionsCorrectly() options.PartitionKeyAttribute.Should().Be("pk"); options.SortKeyAttribute.Should().Be("sk"); } - - [Theory, BaseAutoData] + + [Theory] + [Compose] public void AddDynamoDbDistributedLock_WithAction_SetsCustomKeyAttributes(string partitionKey, string sortKey) { var services = new ServiceCollection(); @@ -88,7 +94,7 @@ public void AddDynamoDbDistributedLock_WithAction_SetsCustomKeyAttributes(string options.SortKeyAttribute = sortKey; }); - services.AddSingleton(Substitute.For()); + services.AddSingleton(CreateDynamoDbDouble()); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; @@ -97,7 +103,8 @@ public void AddDynamoDbDistributedLock_WithAction_SetsCustomKeyAttributes(string options.SortKeyAttribute.Should().Be(sortKey); } - [Theory, BaseAutoData] + [Theory] + [Compose] public void AddDynamoDbDistributedLock_WithConfiguration_BindsCustomKeyAttributes(string partitionKey, string sortKey) { var inMemorySettings = new Dictionary @@ -114,7 +121,7 @@ public void AddDynamoDbDistributedLock_WithConfiguration_BindsCustomKeyAttribute var services = new ServiceCollection(); services.AddDynamoDbDistributedLock(configuration); - services.AddSingleton(Substitute.For()); + services.AddSingleton(CreateDynamoDbDouble()); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; @@ -122,7 +129,7 @@ public void AddDynamoDbDistributedLock_WithConfiguration_BindsCustomKeyAttribute options.PartitionKeyAttribute.Should().Be(partitionKey); options.SortKeyAttribute.Should().Be(sortKey); } - + [Fact] public void AddDynamoDbDistributedLock_WithActionAndAwsConfig_SetsUpServiceAndOptions() { @@ -141,7 +148,7 @@ public void AddDynamoDbDistributedLock_WithActionAndAwsConfig_SetsUpServiceAndOp options.TableName = "locks"; options.LockTimeoutSeconds = 45; }, awsOptions); - + var provider = services.BuildServiceProvider(); // Assert @@ -153,7 +160,7 @@ public void AddDynamoDbDistributedLock_WithActionAndAwsConfig_SetsUpServiceAndOp options.LockTimeoutSeconds.Should().Be(45); options.PartitionKeyAttribute.Should().Be("pk"); options.SortKeyAttribute.Should().Be("sk"); - + var dynamoDbClient = provider.GetRequiredService(); dynamoDbClient.Config.ServiceURL.Should().Be("http://localhost/"); } diff --git a/test/DynamoDb.DistributedLock.Tests/Retry/ExponentialBackoffRetryPolicyTests.cs b/test/DynamoDb.DistributedLock.Tests/Retry/ExponentialBackoffRetryPolicyTests.cs index 494fb94..2d169b2 100644 --- a/test/DynamoDb.DistributedLock.Tests/Retry/ExponentialBackoffRetryPolicyTests.cs +++ b/test/DynamoDb.DistributedLock.Tests/Retry/ExponentialBackoffRetryPolicyTests.cs @@ -1,22 +1,17 @@ -using System; using System.Diagnostics.Metrics; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture.Xunit3; using AwesomeAssertions; +using Compono.XunitV3; using DynamoDb.DistributedLock.Metrics; using DynamoDb.DistributedLock.Retry; using DynamoDb.DistributedLock.Tests.Metrics; -using DynamoDb.DistributedLock.Tests.TestKit.Attributes; -using DynamoDb.DistributedLock.Tests.TestKit.Extensions; -using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using DynamoDb.DistributedLock.Tests.TestKit.Profiles; namespace DynamoDb.DistributedLock.Tests.Retry; public class ExponentialBackoffRetryPolicyTests { [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenOptionsIsNull_ShouldThrowArgumentNullException(ILockMetrics lockMetrics) { var act = () => new ExponentialBackoffRetryPolicy(null!, lockMetrics); @@ -24,9 +19,9 @@ public void Constructor_WhenOptionsIsNull_ShouldThrowArgumentNullException(ILock act.Should().Throw() .Which.ParamName.Should().Be("options"); } - + [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public void Constructor_WhenLockMetricsIsNull_ShouldThrowArgumentNullException(RetryOptions retryOptions) { var act = () => new ExponentialBackoffRetryPolicy(retryOptions, null!); @@ -36,7 +31,7 @@ public void Constructor_WhenLockMetricsIsNull_ShouldThrowArgumentNullException(R } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenOperationIsNull_ShouldThrowArgumentNullException( ExponentialBackoffRetryPolicy sut) { @@ -47,7 +42,7 @@ public async Task ExecuteAsync_WhenOperationIsNull_ShouldThrowArgumentNullExcept } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenShouldRetryIsNull_ShouldThrowArgumentNullException( ExponentialBackoffRetryPolicy sut) { @@ -58,7 +53,7 @@ public async Task ExecuteAsync_WhenShouldRetryIsNull_ShouldThrowArgumentNullExce } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenOperationSucceedsOnFirstAttempt_ShouldReturnResult( RetryOptions options, ILockMetrics lockMetrics, @@ -78,7 +73,7 @@ public async Task ExecuteAsync_WhenOperationSucceedsOnFirstAttempt_ShouldReturnR } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenOperationFailsButShouldNotRetry_ShouldThrowImmediately( RetryOptions options, ILockMetrics lockMetrics) @@ -102,9 +97,9 @@ public async Task ExecuteAsync_WhenOperationFailsButShouldNotRetry_ShouldThrowIm } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenOperationFailsAndShouldRetry_ShouldRetryUpToMaxAttempts( - RetryOptions options, ILockMetrics lockMetrics, TestMetricAggregator metricAggregator) + [Shared] Meter meter, RetryOptions options, ILockMetrics lockMetrics, TestMetricAggregator metricAggregator) { options.MaxAttempts = 3; options.BaseDelay = TimeSpan.FromMilliseconds(1); // Fast test @@ -129,8 +124,9 @@ public async Task ExecuteAsync_WhenOperationFailsAndShouldRetry_ShouldRetryUpToM } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenOperationSucceedsAfterRetries_ShouldReturnResult( + [Shared] Meter meter, RetryOptions options, ILockMetrics lockMetrics, TestMetricAggregator metricAggregator, @@ -151,13 +147,13 @@ public async Task ExecuteAsync_WhenOperationSucceedsAfterRetries_ShouldReturnRes result.Should().Be(expectedResult); operationCalled.Should().Be(3); - + metricAggregator.Collect(MetricNames.RetryAttempt).Should().HaveCount(2); // 2 retries after the first failure before success metricAggregator.Collect(MetricNames.RetriesExhausted).Should().BeEmpty(); // Should not be exhausted } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task ExecuteAsync_WhenCancellationRequested_ShouldThrowOperationCanceledException( RetryOptions options, ILockMetrics lockMetrics) @@ -178,4 +174,4 @@ public async Task ExecuteAsync_WhenCancellationRequested_ShouldThrowOperationCan await act.Should().ThrowAsync(); } -} \ No newline at end of file +} diff --git a/test/DynamoDb.DistributedLock.Tests/Retry/RetryIntegrationTests.cs b/test/DynamoDb.DistributedLock.Tests/Retry/RetryIntegrationTests.cs index 8b1d91a..55db9a2 100644 --- a/test/DynamoDb.DistributedLock.Tests/Retry/RetryIntegrationTests.cs +++ b/test/DynamoDb.DistributedLock.Tests/Retry/RetryIntegrationTests.cs @@ -1,34 +1,30 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; -using AutoFixture.Xunit3; using AwesomeAssertions; +using Compono; +using Compono.XunitV3; using DynamoDb.DistributedLock.Metrics; -using DynamoDb.DistributedLock.Retry; -using DynamoDb.DistributedLock.Tests.TestKit.Attributes; +using DynamoDb.DistributedLock.Tests.TestKit.Profiles; using Microsoft.Extensions.Options; -using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace DynamoDb.DistributedLock.Tests.Retry; public class RetryIntegrationTests { [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenRetryDisabled_ShouldNotRetryOnFailure( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { - // Arrange + // Arrange - one static response regardless of args; no matching or sequencing needed. options.Value.Retry.Enabled = false; - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ConditionalCheckFailedException("Lock exists")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new ConditionalCheckFailedException("Lock exists")); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -37,15 +33,21 @@ public async Task AcquireLockAsync_WhenRetryDisabled_ShouldNotRetryOnFailure( // Assert result.Should().BeFalse(); - await dynamo.Received(1).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Once(); } + // ADR-0054 (sequential/call-count-based responses): the SUT's own internal retry loop makes + // all 3 PutItemAsync calls inside one `await sut.AcquireLockAsync(...)`, with no opportunity + // for the test to reconfigure the double between calls - ReturnsSequence(...) configures the + // fail/fail/succeed sequence up front instead. [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenRetryEnabledAndEventuallySucceeds_ShouldReturnTrue( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { @@ -54,15 +56,12 @@ public async Task AcquireLockAsync_WhenRetryEnabledAndEventuallySucceeds_ShouldR options.Value.Retry.MaxAttempts = 3; options.Value.Retry.BaseDelay = TimeSpan.FromMilliseconds(1); // Fast test - var callCount = 0; - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - callCount++; - if (callCount < 3) - throw new ConditionalCheckFailedException("Lock exists"); - return new PutItemResponse(); - }); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .ReturnsSequence( + SequenceOutcome.Throw(new ConditionalCheckFailedException("Lock exists")), + SequenceOutcome.Throw(new ConditionalCheckFailedException("Lock exists")), + Task.FromResult(new PutItemResponse())); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -71,25 +70,28 @@ public async Task AcquireLockAsync_WhenRetryEnabledAndEventuallySucceeds_ShouldR // Assert result.Should().BeTrue(); - await dynamo.Received(3).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Exactly(3); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenRetryEnabledButMaxAttemptsReached_ShouldReturnFalse( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { - // Arrange + // Arrange - fails identically on every attempt; no matching or sequencing needed. options.Value.Retry.Enabled = true; options.Value.Retry.MaxAttempts = 2; options.Value.Retry.BaseDelay = TimeSpan.FromMilliseconds(1); // Fast test - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ConditionalCheckFailedException("Lock exists")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new ConditionalCheckFailedException("Lock exists")); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -98,15 +100,19 @@ public async Task AcquireLockAsync_WhenRetryEnabledButMaxAttemptsReached_ShouldR // Assert result.Should().BeFalse(); - await dynamo.Received(2).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Exactly(2); } + // ADR-0054 (sequential/call-count-based responses) - same reasoning as + // AcquireLockAsync_WhenRetryEnabledAndEventuallySucceeds_ShouldReturnTrue above. [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenRetryEnabledWithThrottling_ShouldRetryOnProvisionedThroughputExceeded( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { @@ -115,15 +121,12 @@ public async Task AcquireLockAsync_WhenRetryEnabledWithThrottling_ShouldRetryOnP options.Value.Retry.MaxAttempts = 3; options.Value.Retry.BaseDelay = TimeSpan.FromMilliseconds(1); // Fast test - var callCount = 0; - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - callCount++; - if (callCount < 3) - throw new ProvisionedThroughputExceededException("Throttled"); - return new PutItemResponse(); - }); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .ReturnsSequence( + SequenceOutcome.Throw(new ProvisionedThroughputExceededException("Throttled")), + SequenceOutcome.Throw(new ProvisionedThroughputExceededException("Throttled")), + Task.FromResult(new PutItemResponse())); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -132,24 +135,27 @@ public async Task AcquireLockAsync_WhenRetryEnabledWithThrottling_ShouldRetryOnP // Assert result.Should().BeTrue(); - await dynamo.Received(3).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Exactly(3); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenRetryEnabledButNonRetriableException_ShouldThrowImmediately( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { - // Arrange + // Arrange - one static response regardless of args; no matching or sequencing needed. options.Value.Retry.Enabled = true; options.Value.Retry.MaxAttempts = 3; - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ArgumentException("Non-retriable exception")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new ArgumentException("Non-retriable exception")); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -157,15 +163,19 @@ public async Task AcquireLockAsync_WhenRetryEnabledButNonRetriableException_Shou var act = async () => await sut.AcquireLockAsync(resourceId, ownerId); await act.Should().ThrowAsync(); - await dynamo.Received(1).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Once(); } + // ADR-0054 (sequential/call-count-based responses) - same reasoning as + // AcquireLockAsync_WhenRetryEnabledAndEventuallySucceeds_ShouldReturnTrue above. [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockHandleAsync_WhenRetryEnabledAndSucceeds_ShouldReturnHandle( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { @@ -174,15 +184,11 @@ public async Task AcquireLockHandleAsync_WhenRetryEnabledAndSucceeds_ShouldRetur options.Value.Retry.MaxAttempts = 3; options.Value.Retry.BaseDelay = TimeSpan.FromMilliseconds(1); // Fast test - var callCount = 0; - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - callCount++; - if (callCount < 2) - throw new ConditionalCheckFailedException("Lock exists"); - return new PutItemResponse(); - }); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .ReturnsSequence( + SequenceOutcome.Throw(new ConditionalCheckFailedException("Lock exists")), + Task.FromResult(new PutItemResponse())); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -194,25 +200,28 @@ public async Task AcquireLockHandleAsync_WhenRetryEnabledAndSucceeds_ShouldRetur result!.ResourceId.Should().Be(resourceId); result.OwnerId.Should().Be(ownerId); result.IsAcquired.Should().BeTrue(); - await dynamo.Received(2).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Exactly(2); } [Theory] - [DynamoDbDistributedLockAutoData] + [Compose] public async Task AcquireLockAsync_WhenRetryEnabledAndThrottlingExhaustsRetries_ShouldReturnFalse( - [Frozen] IAmazonDynamoDB dynamo, - [Frozen] IOptions options, - [Frozen] ILockMetrics lockMetrics, + IAmazonDynamoDB dynamo, + IOptions options, + ILockMetrics lockMetrics, string resourceId, string ownerId) { - // Arrange + // Arrange - fails identically on every attempt; no matching or sequencing needed. options.Value.Retry.Enabled = true; options.Value.Retry.MaxAttempts = 2; options.Value.Retry.BaseDelay = TimeSpan.FromMilliseconds(1); // Fast test - dynamo.PutItemAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new ProvisionedThroughputExceededException("Throttled")); + dynamo.Configure() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Throws(new ProvisionedThroughputExceededException("Throttled")); var sut = new DynamoDbDistributedLock(dynamo, options, lockMetrics); @@ -221,6 +230,8 @@ public async Task AcquireLockAsync_WhenRetryEnabledAndThrottlingExhaustsRetries_ // Assert result.Should().BeFalse(); - await dynamo.Received(2).PutItemAsync(Arg.Any(), Arg.Any()); + dynamo.Verify() + .PutItemAsync(new PutItemRequest(), CancellationToken.None) + .Exactly(2); } -} \ No newline at end of file +} diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/AssemblyComposable.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/AssemblyComposable.cs new file mode 100644 index 0000000..fb62ad4 --- /dev/null +++ b/test/DynamoDb.DistributedLock.Tests/TestKit/AssemblyComposable.cs @@ -0,0 +1,10 @@ +using Compono; +using DynamoDb.DistributedLock; + +// DynamoDbLockOptions is only ever reached indirectly - through IOptions +// requests that OptionsValueProvider resolves at runtime, which the generator's static +// Create()/CreateMany() call-site discovery walk can't see. DynamoDbLockOptions itself also +// lives in a referenced assembly (the production DynamoDb.DistributedLock project), so it's opted in +// here rather than annotated directly - see composition-model.md's [Composable] discovery-gap +// guidance. +[assembly: Composable(typeof(DynamoDbLockOptions))] diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Attributes/BaseAutoDataAttribute.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Attributes/BaseAutoDataAttribute.cs deleted file mode 100644 index 167be27..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/Attributes/BaseAutoDataAttribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AutoFixture; -using AutoFixture.AutoNSubstitute; -using AutoFixture.Xunit3; - -namespace DynamoDb.DistributedLock.Tests.TestKit.Attributes; - -/// -/// Provides AutoFixture-based data with NSubstitute for use with xUnit theories. -/// -public class BaseAutoDataAttribute() : AutoDataAttribute(() => -{ - var fixture = new Fixture(); - fixture.Customize(new AutoNSubstituteCustomization()); - return fixture; -}); - -/// -/// Provides inline arguments combined with customizations for use with xUnit theories. -/// -public class InlineBaseAutoDataAttribute(params object[] values) - : InlineAutoDataAttribute(new BaseAutoDataAttribute(), values); \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Attributes/DynamoDbDistributedLockAutoDataAttribute.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Attributes/DynamoDbDistributedLockAutoDataAttribute.cs deleted file mode 100644 index e31aa03..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/Attributes/DynamoDbDistributedLockAutoDataAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AutoFixture; -using AutoFixture.Xunit3; -using DynamoDb.DistributedLock.Tests.TestKit.Customizations; - -namespace DynamoDb.DistributedLock.Tests.TestKit.Attributes; - -/// -/// Provides AutoFixture-based data with NSubstitute and DynamoDb.DistributedLock-specific customizations for use with xUnit theories. -/// -public class DynamoDbDistributedLockAutoDataAttribute() : AutoDataAttribute(() => -{ - var fixture = new Fixture(); - fixture.Customize(new LockingCustomization()); - return fixture; -}); - -/// -/// Provides inline arguments combined with customizations for use with xUnit theories. -/// -public class InlineDynamoDbDistributedLockAutoDataAttribute(params object[] values) - : InlineAutoDataAttribute(new DynamoDbDistributedLockAutoDataAttribute(), values); - \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Customizations/LockingCustomization.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Customizations/LockingCustomization.cs deleted file mode 100644 index 12edb34..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/Customizations/LockingCustomization.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Amazon.DynamoDBv2; -using AutoFixture; -using AutoFixture.AutoNSubstitute; -using DynamoDb.DistributedLock.Tests.TestKit.Extensions; - -namespace DynamoDb.DistributedLock.Tests.TestKit.Customizations; - -/// -/// Applies custom fixture configuration for testing DynamoDbDistributedLock. -/// -public class LockingCustomization : ICustomization -{ - public void Customize(IFixture fixture) - { - fixture.Customize(new AutoNSubstituteCustomization()); - - // add metrics customizations - fixture.AddMetrics(); - - // 🔒 Inject a null-value IOptions for specific test scenarios - fixture.AddNullDynamoDbLockOptions(); - - fixture.AddlDynamoDbLockOptions(); - - // 🔗 Add DistributedLockHandle customization - fixture.AddDistributedLockHandle(); - - // Add DynamoDbDistributedLock - fixture.AddDynamoDbDistributedLock(); - - // 🔄 Add retry policy customization - fixture.AddRetryPolicy(); - - // ❄️ Freeze core constructor dependencies - fixture.Freeze(); - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Extensions/FixtureExtensions.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Extensions/FixtureExtensions.cs deleted file mode 100644 index 6c71884..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/Extensions/FixtureExtensions.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Diagnostics.Metrics; -using AutoFixture; -using AutoFixture.Kernel; -using DynamoDb.DistributedLock.Tests.TestKit.SpecimenBuilders; - -namespace DynamoDb.DistributedLock.Tests.TestKit.Extensions; - -/// -/// Provides extension methods for configuring with DynamoDb.DistributedLock test customizations. -/// -public static class FixtureExtensions -{ - /// - /// Adds a customization that injects an with a null Value when a parameter named nullOptions is requested. - /// - /// The AutoFixture instance to customize. - /// The same instance for chaining. - public static IFixture AddNullDynamoDbLockOptions(this IFixture fixture) - { - fixture.Customizations.Add(new NullOptionsSpecimenBuilder()); - return fixture; - } - - /// - /// Adds a customization that injects an . - /// - /// The AutoFixture instance to customize. - /// The same instance for chaining. - public static IFixture AddlDynamoDbLockOptions(this IFixture fixture) - { - fixture.Customizations.Add(new OptionsSpecimenBuilder()); - return fixture; - } - - /// - /// Adds a customization that creates instances of for testing. - /// - /// The AutoFixture instance to customize. - /// The same instance for chaining. - public static IFixture AddDistributedLockHandle(this IFixture fixture) - { - fixture.Customizations.Add(new DistributedLockHandleSpecimenBuilder()); - return fixture; - } - - /// - /// Adds a customization that creates retry policy instances for testing. - /// - /// The AutoFixture instance to customize. - /// The same instance for chaining. - public static IFixture AddRetryPolicy(this IFixture fixture) - { - fixture.Customizations.Add(new RetryPolicySpecimenBuilder()); - return fixture; - } - - /// - /// Adds customization that creates instances needed for metrics collection in tests. - /// - /// The AutoFixture instance to customize. - /// The same instance for chaining. - public static IFixture AddMetrics(this IFixture fixture) - { - fixture.Customizations.Add(new MetricsSpecimenBuilder()); - fixture.Freeze(); - return fixture; - } - - /// - /// Adds customizations for DynamoDbDistributedLock creation - /// - /// - /// The same instance for chaining. - public static IFixture AddDynamoDbDistributedLock(this IFixture fixture) - { - // we need constructor selection to be greedy to pick up the optional parameters - fixture.Customize(x => - x.FromFactory(new MethodInvoker(new GreedyConstructorQuery()))); - return fixture; - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Profiles/DynamoDbDistributedLockCompositionDefaults.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Profiles/DynamoDbDistributedLockCompositionDefaults.cs new file mode 100644 index 0000000..14d8b52 --- /dev/null +++ b/test/DynamoDb.DistributedLock.Tests/TestKit/Profiles/DynamoDbDistributedLockCompositionDefaults.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.Metrics; +using Amazon.DynamoDBv2; +using Compono; +using DynamoDb.DistributedLock.Metrics; +using DynamoDb.DistributedLock.Retry; +using DynamoDb.DistributedLock.Tests.TestKit.Providers; +using Microsoft.Extensions.Options; + +namespace DynamoDb.DistributedLock.Tests.TestKit.Profiles; + +/// +/// Registrations shared by every DynamoDb.DistributedLock composition profile, regardless of how +/// itself is resolved: a real Meter/ILockMetrics pair (so a +/// [Shared] Meter theory parameter lets a TestMetricAggregator observe what the composed SUT +/// actually publishes), the null-options-by-name provider, and the constructor selections required +/// by types with more than one accessible constructor. +/// +internal static class DynamoDbDistributedLockCompositionDefaults +{ + internal static void Configure(CompositionBuilder builder) + { + builder.Register(_ => new Meter(MetricNames.MeterName)); + builder.Register(context => new LockMetrics(context.Resolve())); + builder.AddSemanticProvider(new OptionsValueProvider()); + + builder.For() + .UseConstructor, ILockMetrics>(); + builder.For() + .UseConstructor(); + // Meter has 4 accessible constructors (CMP0001). The generator still needs a compile-time + // plan for it even though Register above always supplies the real runtime value - + // this selection is never actually invoked. + builder.For().UseConstructor(); + } +} diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Profiles/DynamoDbDistributedLockGeneratedTestDoubleProfile.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Profiles/DynamoDbDistributedLockGeneratedTestDoubleProfile.cs new file mode 100644 index 0000000..d5e1bb7 --- /dev/null +++ b/test/DynamoDb.DistributedLock.Tests/TestKit/Profiles/DynamoDbDistributedLockGeneratedTestDoubleProfile.cs @@ -0,0 +1,20 @@ +using Compono; + +namespace DynamoDb.DistributedLock.Tests.TestKit.Profiles; + +/// +/// Composition profile where resolves to a +/// Compono-generated test double. The only profile this project needs - Compono.TestDoubles now +/// supports both overload-aware argument matching (ADR-0044 Amendment 21, +/// DeleteItemAsyncMatching(...)) and sequential/call-count-based responses (ADR-0054, +/// .ReturnsSequence(...)) directly on the generated double, so the earlier hand-rolled- +/// NSubstitute fallback profile this project used is gone. +/// +public sealed class DynamoDbDistributedLockGeneratedTestDoubleProfile : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) + { + builder.UseGeneratedTestDoubles(); + DynamoDbDistributedLockCompositionDefaults.Configure(builder); + } +} diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/Providers/OptionsValueProvider.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/Providers/OptionsValueProvider.cs new file mode 100644 index 0000000..ec579c2 --- /dev/null +++ b/test/DynamoDb.DistributedLock.Tests/TestKit/Providers/OptionsValueProvider.cs @@ -0,0 +1,23 @@ +using Compono; +using Microsoft.Extensions.Options; + +namespace DynamoDb.DistributedLock.Tests.TestKit.Providers; + +/// +/// Supplies of , returning a +/// null-valued instance for a parameter named "nullOptions" so constructor-null-check tests can +/// request it by name, and an ordinary composed instance otherwise. +/// +public sealed class OptionsValueProvider : ICompositionValueProvider +{ + public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context) + { + if (request.RequestedType != typeof(IOptions)) + return CompositionProviderResult.NotHandled; + + if (string.Equals(request.Name, "nullOptions", StringComparison.OrdinalIgnoreCase)) + return CompositionProviderResult.Handled(Options.Create(null!)); + + return CompositionProviderResult.Handled(Options.Create(context.Resolve())); + } +} diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/RequestSpecifications/NullOptionsParameterSpecification.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/RequestSpecifications/NullOptionsParameterSpecification.cs deleted file mode 100644 index 6b56bd3..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/RequestSpecifications/NullOptionsParameterSpecification.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using AutoFixture.Kernel; -using Microsoft.Extensions.Options; - -namespace DynamoDb.DistributedLock.Tests.TestKit.RequestSpecifications; - -/// -/// Matches an IOptions parameter with a specific name to inject a null Value. -/// -public sealed class NullOptionsParameterSpecification : IRequestSpecification - where TOptions : class -{ - public bool IsSatisfiedBy(object request) - { - return request is ParameterInfo pi && - typeof(IOptions).IsAssignableFrom(pi.ParameterType) && - string.Equals(pi.Name, "nullOptions", StringComparison.OrdinalIgnoreCase); - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/RequestSpecifications/OptionsParameterSpecification.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/RequestSpecifications/OptionsParameterSpecification.cs deleted file mode 100644 index bde83b0..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/RequestSpecifications/OptionsParameterSpecification.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Reflection; -using AutoFixture.Kernel; -using Microsoft.Extensions.Options; - -namespace DynamoDb.DistributedLock.Tests.TestKit.RequestSpecifications; - -/// -/// Matches all requests for except when the parameter is named "nullOptions". -/// -public sealed class OptionsParameterSpecification : IRequestSpecification - where TOptions : class -{ - public bool IsSatisfiedBy(object request) - { - // If someone is asking for IOptions directly - if (request is Type typeRequest && typeRequest == typeof(IOptions)) - return true; - - // If the request is for a parameter - if (request is ParameterInfo pi && - typeof(IOptions).IsAssignableFrom(pi.ParameterType) && - !string.Equals(pi.Name, "nullOptions", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/DistributedLockHandleSpecimenBuilder.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/DistributedLockHandleSpecimenBuilder.cs deleted file mode 100644 index 4df2fef..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/DistributedLockHandleSpecimenBuilder.cs +++ /dev/null @@ -1,34 +0,0 @@ -using AutoFixture.Kernel; -using System.Reflection; -using AutoFixture; - -namespace DynamoDb.DistributedLock.Tests.TestKit.SpecimenBuilders; - -/// -/// Creates instances of for testing purposes. -/// -public class DistributedLockHandleSpecimenBuilder : ISpecimenBuilder -{ - public object Create(object request, ISpecimenContext context) - { - if (request is not Type type || type != typeof(DistributedLockHandle)) - return new NoSpecimen(); - - var lockService = context.Create(); - var resourceId = context.Create(); - var ownerId = context.Create(); - var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); // Default to 5 minutes from now - - // Use reflection to create the internal class - var constructor = type.GetConstructor( - BindingFlags.NonPublic | BindingFlags.Instance, - null, - new[] { typeof(IDynamoDbDistributedLock), typeof(string), typeof(string), typeof(DateTimeOffset) }, - null); - - if (constructor == null) - return new NoSpecimen(); - - return constructor.Invoke(new object[] { lockService, resourceId, ownerId, expiresAt }); - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/MetricsSpecimenBuilder.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/MetricsSpecimenBuilder.cs deleted file mode 100644 index 86af7bf..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/MetricsSpecimenBuilder.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Diagnostics.Metrics; -using AutoFixture; -using AutoFixture.Kernel; -using DynamoDb.DistributedLock.Metrics; -using DynamoDb.DistributedLock.Tests.Metrics; -using DynamoDb.DistributedLock.Tests.TestKit.Extensions; -using Microsoft.Extensions.DependencyInjection; - -namespace DynamoDb.DistributedLock.Tests.TestKit.SpecimenBuilders; - -public class MetricsSpecimenBuilder : ISpecimenBuilder -{ - public object Create(object request, ISpecimenContext context) - { - if (request is not Type type) - return new NoSpecimen(); - - if (type == typeof(Meter)) - { - return new Meter(MetricNames.MeterName); - } - - if (type == typeof(TestMetricAggregator)) - { - var meterFactory = context.Create(); - return new TestMetricAggregator(meterFactory); - } - - if (type == typeof(TestMetricAggregator)) - { - var meterFactory = context.Create(); - return new TestMetricAggregator(meterFactory); - } - - if (type == typeof(ILockMetrics)) - { - var meter = context.Create(); - return new LockMetrics(meter); - } - - return new NoSpecimen(); - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/NullOptionsSpecimenBuilder.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/NullOptionsSpecimenBuilder.cs deleted file mode 100644 index ca4ad40..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/NullOptionsSpecimenBuilder.cs +++ /dev/null @@ -1,31 +0,0 @@ -using AutoFixture.Kernel; -using DynamoDb.DistributedLock.Tests.TestKit.RequestSpecifications; -using Microsoft.Extensions.Options; -using NSubstitute; - -namespace DynamoDb.DistributedLock.Tests.TestKit.SpecimenBuilders; - -/// -/// Returns an IOptions<T> where Value is null, when matched by the provided specification. -/// -/// The request specification used to determine whether to return a null-valued IOptions instance. -public sealed class NullOptionsSpecimenBuilder(IRequestSpecification spec) : ISpecimenBuilder - where TOptions : class -{ - public NullOptionsSpecimenBuilder() - : this(new NullOptionsParameterSpecification()) - { - } - - private readonly IRequestSpecification _spec = spec; - - public object Create(object request, ISpecimenContext context) - { - if (!_spec.IsSatisfiedBy(request)) - return new NoSpecimen(); - - var substitute = Substitute.For>(); - substitute.Value.Returns((TOptions)null!); - return substitute; - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/OptionsSpecimenBuilder.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/OptionsSpecimenBuilder.cs deleted file mode 100644 index 2cfd745..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/OptionsSpecimenBuilder.cs +++ /dev/null @@ -1,28 +0,0 @@ -using AutoFixture; -using AutoFixture.Kernel; -using DynamoDb.DistributedLock.Tests.TestKit.RequestSpecifications; -using Microsoft.Extensions.Options; - -namespace DynamoDb.DistributedLock.Tests.TestKit.SpecimenBuilders; - -/// -/// Generates a valid instance when the request satisfies the valid options specification. -/// -public sealed class OptionsSpecimenBuilder(IRequestSpecification spec) : ISpecimenBuilder - where TOptions : class -{ - public OptionsSpecimenBuilder() : this(new OptionsParameterSpecification()) - { - } - - private readonly IRequestSpecification _spec = spec; - - public object Create(object request, ISpecimenContext context) - { - if (!_spec.IsSatisfiedBy(request)) - return new NoSpecimen(); - - var value = context.Create(); - return Options.Create(value); - } -} \ No newline at end of file diff --git a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/RetryPolicySpecimenBuilder.cs b/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/RetryPolicySpecimenBuilder.cs deleted file mode 100644 index 86f6df4..0000000 --- a/test/DynamoDb.DistributedLock.Tests/TestKit/SpecimenBuilders/RetryPolicySpecimenBuilder.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Diagnostics.Metrics; -using AutoFixture; -using AutoFixture.Kernel; -using DynamoDb.DistributedLock.Metrics; -using DynamoDb.DistributedLock.Retry; - -namespace DynamoDb.DistributedLock.Tests.TestKit.SpecimenBuilders; - -/// -/// Creates instances of retry policy types for testing purposes. -/// -public class RetryPolicySpecimenBuilder : ISpecimenBuilder -{ - public object Create(object request, ISpecimenContext context) - { - if (request is not Type type) - return new NoSpecimen(); - - if (type == typeof(ExponentialBackoffRetryPolicy)) - { - var options = new RetryOptions - { - MaxAttempts = 3, - BaseDelay = TimeSpan.FromMilliseconds(10), - MaxDelay = TimeSpan.FromSeconds(1), - BackoffMultiplier = 2.0, - UseJitter = false, - Enabled = false - }; - var lockMetrics = context.Create(); - return new ExponentialBackoffRetryPolicy(options, lockMetrics); - } - - if (type == typeof(RetryOptions)) - { - return new RetryOptions - { - MaxAttempts = 3, - BaseDelay = TimeSpan.FromMilliseconds(10), // Fast for tests - MaxDelay = TimeSpan.FromSeconds(1), - BackoffMultiplier = 2.0, - UseJitter = false, // Deterministic for tests - Enabled = false // Default to disabled for backward compatibility - }; - } - - return new NoSpecimen(); - } -} \ No newline at end of file