Skip to content

Commit

Permalink
Make CloneableRequestMessage.CloneAsync virtual for better unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
nozzlegear committed May 16, 2024
1 parent 5b37e1c commit 26774b5
Show file tree
Hide file tree
Showing 2 changed files with 33 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using FluentAssertions;
using JetBrains.Annotations;
using NSubstitute;
using NSubstitute.Core;
using ShopifySharp.Infrastructure;
using ShopifySharp.Infrastructure.Policies.ExponentialRetry;
using ShopifySharp.Tests.TestClasses;
Expand All @@ -21,16 +20,21 @@ public class ExponentialRetryPolicyTests
private readonly IResponseClassifier _responseClassifier;
private readonly ITaskScheduler _taskScheduler;
private readonly ExecuteRequestAsync<int> _executeRequest;
private readonly TestCloneableRequestMessage _cloneableRequestMessage;

public ExponentialRetryPolicyTests()
{
_responseClassifier = Substitute.For<IResponseClassifier>();
_taskScheduler = Substitute.For<ITaskScheduler>();
_executeRequest = Substitute.For<ExecuteRequestAsync<int>>();
_cloneableRequestMessage = Substitute.For<TestCloneableRequestMessage>();

// Always return a completed task when the scheduler wants to delay, so no actual time is spent waiting during a test
_taskScheduler.DelayAsync(Arg.Any<TimeSpan>())
.Returns(Task.CompletedTask);
// Always have the test request message return itself when cloned
_cloneableRequestMessage.CloneAsync(Arg.Any<CancellationToken>())
.Returns(_cloneableRequestMessage);
}

private ExponentialRetryPolicy SetupPolicy([CanBeNull] Action<ExponentialRetryPolicyOptions> configure = null)
Expand Down Expand Up @@ -67,14 +71,13 @@ public void PolicyConstructor_ShouldValidateOptionsBeforeRunning()
public async Task Run_ShouldReturnResult()
{
const int expectedValue = 5;
var request = new TestCloneableRequestMessage();
var expectedResult = new TestRequestResult<int>(expectedValue);

_executeRequest.Invoke(request)
_executeRequest.Invoke(_cloneableRequestMessage)
.Returns(expectedResult);

var policy = SetupPolicy();
var result = await policy.Run(request, _executeRequest, CancellationToken.None);
var result = await policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

result.Should().NotBeNull();
result.Result.Should().Be(expectedValue);
Expand All @@ -97,15 +100,14 @@ public async Task Run_ShouldDisposeClonedRequestMessages()
public async Task Run_ShouldThrowWhenRequestIsNotRetriableAsync()
{
var ex = new TestShopifyException();
var request = Substitute.For<TestCloneableRequestMessage>();

_executeRequest.When(x => x.Invoke(request))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Throw(ex);
_responseClassifier.IsRetriableException(ex, 1)
.Returns(false);

var policy = SetupPolicy();
var act = () => policy.Run(request, _executeRequest, CancellationToken.None);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

await act.Should().ThrowAsync<TestShopifyException>();
_responseClassifier.Received(1).IsRetriableException(ex, 1);
Expand All @@ -115,14 +117,13 @@ public async Task Run_ShouldThrowWhenRequestIsNotRetriableAsync()
public async Task Run_ShouldRetryWhenRequestIsRetriableAsync()
{
const int expectedValue = 5;
var request = new TestCloneableRequestMessage();
var expectedResult = new TestRequestResult<int>(expectedValue);
var ex = new TestShopifyException();
var iteration = 0;

_executeRequest(Arg.Any<CloneableRequestMessage>())
_executeRequest(_cloneableRequestMessage)
.Returns(expectedResult);
_executeRequest.When(x => x.Invoke(Arg.Any<CloneableRequestMessage>()))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Do(_ =>
{
iteration++;
Expand All @@ -136,27 +137,27 @@ public async Task Run_ShouldRetryWhenRequestIsRetriableAsync()
.Returns(false);

var policy = SetupPolicy();
var act = () => policy.Run(request, _executeRequest, CancellationToken.None);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

var result = await act.Should().NotThrowAsync();
result.Subject.Should().NotBeNull();
result.Subject.Result.Should().Be(expectedValue);
iteration.Should().Be(4);
Received.InOrder(() =>
{
_executeRequest.Invoke(request);
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 1);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any<CancellationToken>());
_executeRequest.Invoke(Arg.Any<CloneableRequestMessage>());
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 2);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any<CancellationToken>());
_executeRequest.Invoke(Arg.Any<CloneableRequestMessage>());
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 3);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(400), Arg.Any<CancellationToken>());
_executeRequest.Invoke(Arg.Any<CloneableRequestMessage>());
_executeRequest.Invoke(_cloneableRequestMessage);
});
}

Expand All @@ -165,10 +166,9 @@ public async Task Run_ShouldHandleNullMaxRetries()
{
const int expectedIterations = 20;
var ex = new TestShopifyException();
var request = new TestCloneableRequestMessage();
var iteration = 0;

_executeRequest.When(x => x.Invoke(Arg.Any<CloneableRequestMessage>()))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Do(_ =>
{
iteration++;
Expand All @@ -186,11 +186,11 @@ public async Task Run_ShouldHandleNullMaxRetries()
x.MaximumRetriesBeforeRequestCancellation = null;
x.MaximumDelayBeforeRequestCancellation = TimeSpan.FromSeconds(5);
});
var act = () => policy.Run(request, _executeRequest, CancellationToken.None);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

await act.Should().ThrowAsync<TestException>();
iteration.Should().Be(expectedIterations);
await _executeRequest.Received(expectedIterations).Invoke(Arg.Any<CloneableRequestMessage>());
await _executeRequest.Received(expectedIterations).Invoke(_cloneableRequestMessage);
// These will receive one less call, because TestException is not caught by the policy and crashes/exits the run
_responseClassifier.Received(expectedIterations - 1).IsRetriableException(ex, Arg.Is<int>(x => x >= 1 && x <= expectedIterations));
await _taskScheduler.Received(expectedIterations - 1).DelayAsync(Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>());
Expand All @@ -201,10 +201,9 @@ public async Task Run_ShouldRetryUntilMaxRetriesIsReachedThenThrow()
{
const int maximumRetries = 2;
var ex = new TestShopifyException();
var request = new TestCloneableRequestMessage();
var iteration = 0;

_executeRequest.When(x => x.Invoke(Arg.Any<CloneableRequestMessage>()))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Do(_ =>
{
iteration++;
Expand All @@ -215,19 +214,19 @@ public async Task Run_ShouldRetryUntilMaxRetriesIsReachedThenThrow()
.Returns(true);

var policy = SetupPolicy(x => x.MaximumRetriesBeforeRequestCancellation = maximumRetries);
var act = () => policy.Run(request, _executeRequest, CancellationToken.None);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

await act.Should().ThrowAsync<ShopifyExponentialRetryCanceledException>()
.Where(x => x.CurrentTry == maximumRetries)
.Where(x => x.MaximumRetries == maximumRetries);
iteration.Should().Be(maximumRetries);
Received.InOrder(() =>
{
_executeRequest.Invoke(request);
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 1);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any<CancellationToken>());
_executeRequest.Invoke(Arg.Any<CloneableRequestMessage>());
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 2);
});
await _taskScheduler.Received(0)
Expand All @@ -240,11 +239,10 @@ public async Task Run_ShouldIncreaseDelayBetweenRetriesUntilItReachesMaximumDela
const int backoffInMilliseconds = 50;
const int expectedIterationsAfterReachingMaximum = 3;
var ex = new TestShopifyException();
var request = new TestCloneableRequestMessage();
var maximumDelayBetweenRetries = TimeSpan.FromMilliseconds(777);
var currentDelaySeenCount = 0;

_executeRequest.When(x => x.Invoke(Arg.Any<CloneableRequestMessage>()))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Throw(ex);
_responseClassifier.IsRetriableException(ex, Arg.Any<int>())
.Returns(true);
Expand All @@ -270,7 +268,7 @@ public async Task Run_ShouldIncreaseDelayBetweenRetriesUntilItReachesMaximumDela
x.MaximumDelayBetweenRetries = maximumDelayBetweenRetries;
x.MaximumDelayBeforeRequestCancellation = TimeSpan.FromMinutes(1);
});
var act = () => policy.Run(request, _executeRequest, CancellationToken.None);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, CancellationToken.None);

await act.Should().ThrowAsync<TestException>();
Received.InOrder(() =>
Expand All @@ -294,11 +292,10 @@ public async Task Run_ShouldRetryUntilMaximumDelayIsReachedThenThrow()
const int maximumDelayMilliseconds = 0;
const int maximumRetries = 100;
var ex = new TestShopifyException();
var request = new TestCloneableRequestMessage();
var timeout = new TimeSpan(maximumDelayMilliseconds);
var inputCancellationToken = CancellationToken.None;

_executeRequest.When(x => x.Invoke(Arg.Any<CloneableRequestMessage>()))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Throw(ex);

_responseClassifier.IsRetriableException(ex, Arg.Any<int>())
Expand All @@ -309,15 +306,15 @@ public async Task Run_ShouldRetryUntilMaximumDelayIsReachedThenThrow()
x.MaximumDelayBeforeRequestCancellation = timeout;
x.MaximumRetriesBeforeRequestCancellation = maximumRetries;
});
var act = () => policy.Run(request, _executeRequest, inputCancellationToken);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, inputCancellationToken);

await act.Should().ThrowAsync<OperationCanceledException>();
// For now, we expect this test to execute the request, check the exception and wait. This
// is because the cancellation token source puts the cancellation on a different thread when
// cancellation is requested.
Received.InOrder(() =>
{
_executeRequest.Invoke(request);
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 1);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(BackoffInMilliseconds), Arg.Any<CancellationToken>());
});
Expand All @@ -328,11 +325,10 @@ public async Task Run_ShouldThrowWhenCancellationTokenIsCanceled()
{
const int maximumRetries = 100;
var ex = new TestShopifyException();
var request = new TestCloneableRequestMessage();
var inputCancellationToken = new CancellationTokenSource();
var iteration = 0;

_executeRequest.When(x => x.Invoke(Arg.Any<CloneableRequestMessage>()))
_executeRequest.When(x => x.Invoke(_cloneableRequestMessage))
.Do(_ =>
{
iteration++;
Expand All @@ -344,16 +340,16 @@ public async Task Run_ShouldThrowWhenCancellationTokenIsCanceled()
.Returns(true);

var policy = SetupPolicy(x => x.MaximumRetriesBeforeRequestCancellation = maximumRetries);
var act = () => policy.Run(request, _executeRequest, inputCancellationToken.Token);
var act = () => policy.Run(_cloneableRequestMessage, _executeRequest, inputCancellationToken.Token);

await act.Should().ThrowAsync<OperationCanceledException>();
Received.InOrder(() =>
{
_executeRequest.Invoke(request);
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 1);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(100), Arg.Any<CancellationToken>());
_executeRequest.Invoke(Arg.Any<CloneableRequestMessage>());
_executeRequest.Invoke(_cloneableRequestMessage);
_responseClassifier.IsRetriableException(ex, 2);
_taskScheduler.DelayAsync(TimeSpan.FromMilliseconds(200), Arg.Any<CancellationToken>());
});
Expand Down
2 changes: 1 addition & 1 deletion ShopifySharp/Infrastructure/CloneableRequestMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public CloneableRequestMessage Clone()
return cloned;
}

public async Task<CloneableRequestMessage> CloneAsync(CancellationToken cancellationToken = default)
public virtual async Task<CloneableRequestMessage> CloneAsync(CancellationToken cancellationToken = default)
{
var newContent = Content is null ? null : await CloneToStreamOrReadOnlyMemoryContent(Content, cancellationToken);
var cloned = new CloneableRequestMessage(RequestUri, Method, newContent);
Expand Down

0 comments on commit 26774b5

Please sign in to comment.