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