test(resilience): cover Elsa.Resilience.Core and lift its coverage gate off the Debug/Release seam (#7971)
`dotnet test test/unit/Elsa.Resilience.Core.UnitTests` exited 1 on a clean checkout with all 56 tests passing. The failure was the coverlet gate, not a test: the project pinned `<Threshold>49</Threshold>` against 48.17% measured line coverage in Debug. Release measures slightly differently and cleared it, so CI (which builds `--configuration Release`) stayed green while every local run — Debug is the default — went red. A red exit for a suite that passes trains people to ignore exit codes. Rather than move the goalposts, cover the code. The gap was concentrated in `ResilientActivityInvoker`, which had no tests at all, plus the serializer, the activity-execution extensions and the retry telemetry listener. `Elsa.Testing.Shared`'s `ActivityTestFixture` was already referenced here and builds a real `ActivityExecutionContext`, which is what all of them needed. Adds 40 tests. The invoker ones drive a real zero-delay Polly retry pipeline, so the telemetry listener is exercised through the actual Polly path rather than being called directly: pass-through when no strategy is configured, the applied strategy recorded on the context, retry-then-succeed, one record per retry carrying identifiers and details, null details dropped, the retries flag and attempt count, exhausted retries rethrowing, and an unhandled exception type not being retried. The extensions tests build a three-level context chain to pin down that the retries flag propagates up the ancestor chain and not down. Line coverage goes 48.17% -> 98.17% in Debug and 97.8% in Release; the five lines still uncovered are defensive early-returns. The threshold moves to 90, below the lower of the two configurations with enough headroom that the Debug/Release delta cannot straddle it again. Verified by deleting the invoker tests once: coverage falls to 68.97% and the gate fails as it should. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b24beadca9
commit
cf23279bf1
|
|
@ -0,0 +1,87 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
using Elsa.Resilience.Extensions;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Testing.Shared.Activities;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Options;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class ActivityExecutionExtensionsTests
|
||||
{
|
||||
[Fact(DisplayName = "Retries-attempted flag should read as false before anything sets it")]
|
||||
public async Task GetRetriesAttemptedFlag_NeverSet_ReturnsFalse()
|
||||
{
|
||||
var context = await ContextFactory.CreateAsync(new WriteLine("test"));
|
||||
|
||||
Assert.False(context.GetRetriesAttemptedFlag());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Setting the retries-attempted flag should make it readable on the same context")]
|
||||
public async Task SetRetriesAttemptedFlag_SetsFlagOnContext()
|
||||
{
|
||||
var context = await ContextFactory.CreateAsync(new WriteLine("test"));
|
||||
|
||||
context.SetRetriesAttemptedFlag();
|
||||
|
||||
Assert.True(context.GetRetriesAttemptedFlag());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Setting the retries-attempted flag should propagate all the way up the ancestor chain")]
|
||||
public async Task SetRetriesAttemptedFlag_PropagatesToAllAncestors()
|
||||
{
|
||||
var (root, middle, leaf) = await CreateThreeLevelChainAsync();
|
||||
|
||||
leaf.SetRetriesAttemptedFlag();
|
||||
|
||||
Assert.True(leaf.GetRetriesAttemptedFlag());
|
||||
Assert.True(middle.GetRetriesAttemptedFlag());
|
||||
Assert.True(root.GetRetriesAttemptedFlag());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Setting the retries-attempted flag should not propagate down to descendants")]
|
||||
public async Task SetRetriesAttemptedFlag_DoesNotPropagateToDescendants()
|
||||
{
|
||||
var (root, middle, leaf) = await CreateThreeLevelChainAsync();
|
||||
|
||||
middle.SetRetriesAttemptedFlag();
|
||||
|
||||
Assert.True(root.GetRetriesAttemptedFlag());
|
||||
Assert.True(middle.GetRetriesAttemptedFlag());
|
||||
Assert.False(leaf.GetRetriesAttemptedFlag());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Setting the resilience strategy should store the model as an activity property")]
|
||||
public async Task SetResilienceStrategy_StoresModelAsProperty()
|
||||
{
|
||||
var context = await ContextFactory.CreateAsync(new WriteLine("test"));
|
||||
var model = JsonNode.Parse("""{"$type":"TestRetryStrategy","id":"test-retry"}""")!;
|
||||
|
||||
context.SetResilienceStrategy(model);
|
||||
|
||||
var stored = context.GetProperty<JsonNode>("ResilienceStrategy");
|
||||
Assert.Same(model, stored);
|
||||
}
|
||||
|
||||
private static async Task<(ActivityExecutionContext Root, ActivityExecutionContext Middle, ActivityExecutionContext Leaf)> CreateThreeLevelChainAsync()
|
||||
{
|
||||
var leafActivity = new WriteLine("leaf");
|
||||
var middleActivity = new TestContainer
|
||||
{
|
||||
Activities = [leafActivity]
|
||||
};
|
||||
var rootActivity = new TestContainer
|
||||
{
|
||||
Activities = [middleActivity]
|
||||
};
|
||||
|
||||
var root = await ContextFactory.CreateAsync(rootActivity);
|
||||
var workflowExecutionContext = root.WorkflowExecutionContext;
|
||||
var middle = await workflowExecutionContext.CreateActivityExecutionContextAsync(middleActivity, new ActivityInvocationOptions { Owner = root });
|
||||
var leaf = await workflowExecutionContext.CreateActivityExecutionContextAsync(leafActivity, new ActivityInvocationOptions { Owner = middle });
|
||||
|
||||
return (root, middle, leaf);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<Include>[Elsa.Resilience.Core]*</Include>
|
||||
<Threshold>49</Threshold>
|
||||
<Threshold>90</Threshold>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class ResilienceCategoryAttributeTests
|
||||
{
|
||||
[ResilienceCategory("HTTP")]
|
||||
private class CategorizedActivity;
|
||||
|
||||
[Fact(DisplayName = "Resilience category should be discoverable through reflection")]
|
||||
public void Category_IsReadableFromCustomAttributes()
|
||||
{
|
||||
var attribute = typeof(CategorizedActivity).GetCustomAttributes(typeof(ResilienceCategoryAttribute), false)
|
||||
.Cast<ResilienceCategoryAttribute>()
|
||||
.Single();
|
||||
|
||||
Assert.Equal("HTTP", attribute.Category);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Resilience category should only be applicable to classes")]
|
||||
public void AttributeUsage_TargetsClassesOnly()
|
||||
{
|
||||
var usage = typeof(ResilienceCategoryAttribute).GetCustomAttributes(typeof(AttributeUsageAttribute), false)
|
||||
.Cast<AttributeUsageAttribute>()
|
||||
.Single();
|
||||
|
||||
Assert.Equal(AttributeTargets.Class, usage.ValidOn);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
using Elsa.Resilience.Extensions;
|
||||
using Elsa.Resilience.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class ResilienceServiceCollectionExtensionsTests
|
||||
{
|
||||
private readonly IServiceCollection _services = new ServiceCollection();
|
||||
|
||||
private ResilienceOptions GetOptions() => _services.BuildServiceProvider().GetRequiredService<IOptions<ResilienceOptions>>().Value;
|
||||
|
||||
[Fact(DisplayName = "AddResilienceStrategy should register the strategy type")]
|
||||
public void AddResilienceStrategy_RegistersType()
|
||||
{
|
||||
_services.AddResilienceStrategy<TestRetryStrategy>();
|
||||
|
||||
Assert.Equal([typeof(TestRetryStrategy)], GetOptions().StrategyTypes);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AddResilienceStrategy should accumulate across calls")]
|
||||
public void AddResilienceStrategy_CalledTwice_RegistersBothTypes()
|
||||
{
|
||||
_services.AddResilienceStrategy<TestRetryStrategy>();
|
||||
_services.AddResilienceStrategy<TestNoopStrategy>();
|
||||
|
||||
Assert.Equal([typeof(TestRetryStrategy), typeof(TestNoopStrategy)], GetOptions().StrategyTypes);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AddResilienceStrategies should register every supplied type")]
|
||||
public void AddResilienceStrategies_RegistersAllTypes()
|
||||
{
|
||||
_services.AddResilienceStrategies([typeof(TestRetryStrategy), typeof(TestNoopStrategy)]);
|
||||
|
||||
Assert.Equal([typeof(TestRetryStrategy), typeof(TestNoopStrategy)], GetOptions().StrategyTypes);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AddResilienceStrategies with an empty sequence should leave options untouched")]
|
||||
public void AddResilienceStrategies_EmptySequence_RegistersNothing()
|
||||
{
|
||||
_services.AddResilienceStrategies([]);
|
||||
|
||||
Assert.Empty(GetOptions().StrategyTypes);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Elsa.Expressions.Models;
|
||||
using Elsa.Resilience.Models;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class ResilienceStrategyConfigTests
|
||||
{
|
||||
[Fact(DisplayName = "Config in identifier mode should round-trip through a JSON node")]
|
||||
public void SerializeToNode_IdentifierMode_RoundTrips()
|
||||
{
|
||||
var config = new ResilienceStrategyConfig
|
||||
{
|
||||
Mode = ResilienceStrategyConfigMode.Identifier,
|
||||
StrategyId = "my-strategy"
|
||||
};
|
||||
|
||||
var result = ResilienceStrategyConfig.Deserialize(config.SerializeToNode());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ResilienceStrategyConfigMode.Identifier, result.Mode);
|
||||
Assert.Equal("my-strategy", result.StrategyId);
|
||||
Assert.Null(result.Expression);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Config in expression mode should round-trip its expression through a JSON node")]
|
||||
public void SerializeToNode_ExpressionMode_RoundTripsExpression()
|
||||
{
|
||||
var config = new ResilienceStrategyConfig
|
||||
{
|
||||
Mode = ResilienceStrategyConfigMode.Expression,
|
||||
Expression = new("JavaScript", "getStrategy()")
|
||||
};
|
||||
|
||||
var result = ResilienceStrategyConfig.Deserialize(config.SerializeToNode());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ResilienceStrategyConfigMode.Expression, result.Mode);
|
||||
Assert.NotNull(result.Expression);
|
||||
Assert.Equal("JavaScript", result.Expression.Type);
|
||||
Assert.Equal("getStrategy()", result.Expression.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Deserializing a null node should return null")]
|
||||
public void Deserialize_NullNode_ReturnsNull()
|
||||
{
|
||||
Assert.Null(ResilienceStrategyConfig.Deserialize(null));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Deserializing an unknown mode should fail rather than silently defaulting")]
|
||||
public void Deserialize_UnknownMode_Throws()
|
||||
{
|
||||
var node = JsonNode.Parse("""{"mode":"NotAMode"}""");
|
||||
|
||||
Assert.ThrowsAny<Exception>(() => ResilienceStrategyConfig.Deserialize(node));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
using System.Text.Json;
|
||||
using Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
using Elsa.Resilience.Options;
|
||||
using Elsa.Resilience.Serialization;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class ResilienceStrategySerializerTests
|
||||
{
|
||||
private readonly ResilienceStrategySerializer _serializer = CreateSerializer(typeof(TestRetryStrategy), typeof(TestNoopStrategy));
|
||||
|
||||
private static ResilienceStrategySerializer CreateSerializer(params Type[] strategyTypes)
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new ResilienceOptions
|
||||
{
|
||||
StrategyTypes = strategyTypes.ToList()
|
||||
});
|
||||
|
||||
return new(options);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should write a type discriminator named after the strategy type")]
|
||||
public void Serialize_RegisteredStrategy_WritesTypeDiscriminator()
|
||||
{
|
||||
var json = _serializer.Serialize(new TestRetryStrategy());
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
Assert.Equal(nameof(TestRetryStrategy), document.RootElement.GetProperty("$type").GetString());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should write property names in camel case")]
|
||||
public void Serialize_RegisteredStrategy_UsesCamelCasePropertyNames()
|
||||
{
|
||||
var json = _serializer.Serialize(new TestRetryStrategy
|
||||
{
|
||||
Id = "my-strategy",
|
||||
MaxRetryAttempts = 7
|
||||
});
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
Assert.Equal("my-strategy", document.RootElement.GetProperty("id").GetString());
|
||||
Assert.Equal(7, document.RootElement.GetProperty("maxRetryAttempts").GetInt32());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should write enums as strings")]
|
||||
public void Serialize_StrategyWithEnum_WritesEnumAsString()
|
||||
{
|
||||
var json = _serializer.Serialize(new TestNoopStrategy
|
||||
{
|
||||
Flavor = TestStrategyFlavor.Fancy
|
||||
});
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
Assert.Equal(nameof(TestStrategyFlavor.Fancy), document.RootElement.GetProperty("flavor").GetString());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should round-trip a strategy back into its concrete type")]
|
||||
public void Deserialize_SerializedStrategy_ReturnsConcreteType()
|
||||
{
|
||||
var json = _serializer.Serialize(new TestRetryStrategy
|
||||
{
|
||||
Id = "round-trip",
|
||||
DisplayName = "Round Trip",
|
||||
MaxRetryAttempts = 4
|
||||
});
|
||||
|
||||
var strategy = Assert.IsType<TestRetryStrategy>(_serializer.Deserialize(json));
|
||||
|
||||
Assert.Equal("round-trip", strategy.Id);
|
||||
Assert.Equal("Round Trip", strategy.DisplayName);
|
||||
Assert.Equal(4, strategy.MaxRetryAttempts);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should read property names case-insensitively")]
|
||||
public void Deserialize_PascalCasePropertyNames_ReadsValues()
|
||||
{
|
||||
var json = $$"""{"$type":"{{nameof(TestRetryStrategy)}}","Id":"pascal","MaxRetryAttempts":3}""";
|
||||
|
||||
var strategy = Assert.IsType<TestRetryStrategy>(_serializer.Deserialize(json));
|
||||
|
||||
Assert.Equal("pascal", strategy.Id);
|
||||
Assert.Equal(3, strategy.MaxRetryAttempts);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should read numbers written as strings")]
|
||||
public void Deserialize_NumberAsString_ReadsNumber()
|
||||
{
|
||||
var json = $$"""{"$type":"{{nameof(TestRetryStrategy)}}","maxRetryAttempts":"5"}""";
|
||||
|
||||
var strategy = Assert.IsType<TestRetryStrategy>(_serializer.Deserialize(json));
|
||||
|
||||
Assert.Equal(5, strategy.MaxRetryAttempts);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should round-trip a heterogeneous list of strategies")]
|
||||
public void SerializeMany_MixedStrategies_RoundTripsEachConcreteType()
|
||||
{
|
||||
var json = _serializer.SerializeMany([
|
||||
new TestRetryStrategy { Id = "first" },
|
||||
new TestNoopStrategy { Id = "second" }
|
||||
]);
|
||||
|
||||
var strategies = _serializer.DeserializeMany(json).ToList();
|
||||
|
||||
Assert.Collection(strategies,
|
||||
s => Assert.Equal("first", Assert.IsType<TestRetryStrategy>(s).Id),
|
||||
s => Assert.Equal("second", Assert.IsType<TestNoopStrategy>(s).Id));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Serializer should reject strategy types that were not registered")]
|
||||
public void Serialize_UnregisteredStrategy_Throws()
|
||||
{
|
||||
var serializer = CreateSerializer(typeof(TestNoopStrategy));
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => serializer.Serialize(new TestRetryStrategy()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
using Elsa.Expressions.Models;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
using Elsa.Resilience.Extensions;
|
||||
using Elsa.Resilience.Models;
|
||||
using Elsa.Resilience.Options;
|
||||
using Elsa.Resilience.Serialization;
|
||||
using Elsa.Workflows;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class ResilientActivityInvokerTests
|
||||
{
|
||||
private readonly IResilienceStrategyConfigEvaluator _evaluator = Substitute.For<IResilienceStrategyConfigEvaluator>();
|
||||
private readonly IRetryAttemptRecorder _recorder = Substitute.For<IRetryAttemptRecorder>();
|
||||
private readonly IIdentityGenerator _identityGenerator = Substitute.For<IIdentityGenerator>();
|
||||
private readonly TestResilientActivity _activity = new();
|
||||
private readonly ResilientActivityInvoker _invoker;
|
||||
|
||||
public ResilientActivityInvokerTests()
|
||||
{
|
||||
_identityGenerator.GenerateId().Returns(_ => Guid.NewGuid().ToString("N"));
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new ResilienceOptions
|
||||
{
|
||||
StrategyTypes = [typeof(TestRetryStrategy)]
|
||||
});
|
||||
|
||||
_invoker = new(_evaluator, _recorder, _identityGenerator, new ResilienceStrategySerializer(options));
|
||||
|
||||
// Default to "no strategy configured"; tests that need one call SetupStrategy.
|
||||
SetupStrategy(null);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker without a strategy should run the action once and return its result")]
|
||||
public async Task InvokeAsync_NoStrategy_RunsActionOnce()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
var invocations = 0;
|
||||
|
||||
var result = await _invoker.InvokeAsync(_activity, context, () =>
|
||||
{
|
||||
invocations++;
|
||||
return Task.FromResult("done");
|
||||
});
|
||||
|
||||
Assert.Equal("done", result);
|
||||
Assert.Equal(1, invocations);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker without a strategy should leave the context untouched")]
|
||||
public async Task InvokeAsync_NoStrategy_DoesNotAnnotateContext()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, () => Task.FromResult("done"));
|
||||
|
||||
Assert.Null(context.GetProperty<object>("ResilienceStrategy"));
|
||||
Assert.False(context.GetRetriesAttemptedFlag());
|
||||
await _recorder.DidNotReceive().RecordAsync(Arg.Any<RecordRetryAttemptsContext>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should pass the activity's configured strategy config to the evaluator")]
|
||||
public async Task InvokeAsync_ActivityWithStrategyConfig_PassesConfigToEvaluator()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
_activity.CustomProperties["resilienceStrategy"] = new ResilienceStrategyConfig
|
||||
{
|
||||
Mode = ResilienceStrategyConfigMode.Identifier,
|
||||
StrategyId = "my-strategy"
|
||||
};
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, () => Task.FromResult("done"));
|
||||
|
||||
await _evaluator.Received(1).EvaluateAsync(
|
||||
Arg.Is<ResilienceStrategyConfig>(c => c.Mode == ResilienceStrategyConfigMode.Identifier && c.StrategyId == "my-strategy"),
|
||||
context.ExpressionExecutionContext,
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker without a configured strategy should pass a null config to the evaluator")]
|
||||
public async Task InvokeAsync_ActivityWithoutStrategyConfig_PassesNullConfigToEvaluator()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, () => Task.FromResult("done"));
|
||||
|
||||
await _evaluator.Received(1).EvaluateAsync(null, context.ExpressionExecutionContext, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker with a strategy should record the applied strategy on the context")]
|
||||
public async Task InvokeAsync_WithStrategy_RecordsAppliedStrategyOnContext()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy { Id = "applied-strategy" });
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, () => Task.FromResult("done"));
|
||||
|
||||
var model = context.GetProperty<System.Text.Json.Nodes.JsonNode>("ResilienceStrategy");
|
||||
Assert.NotNull(model);
|
||||
Assert.Equal(nameof(TestRetryStrategy), model["$type"]!.GetValue<string>());
|
||||
Assert.Equal("applied-strategy", model["id"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker with a strategy should not record retry attempts when the action succeeds first time")]
|
||||
public async Task InvokeAsync_WithStrategy_ActionSucceedsImmediately_RecordsNoAttempts()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy());
|
||||
|
||||
var result = await _invoker.InvokeAsync(_activity, context, () => Task.FromResult("done"));
|
||||
|
||||
Assert.Equal("done", result);
|
||||
await _recorder.DidNotReceive().RecordAsync(Arg.Any<RecordRetryAttemptsContext>());
|
||||
Assert.False(context.GetRetriesAttemptedFlag());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should retry a transient failure and return the eventual result")]
|
||||
public async Task InvokeAsync_ActionFailsThenSucceeds_RetriesAndReturnsResult()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy { MaxRetryAttempts = 3 });
|
||||
var invocations = 0;
|
||||
|
||||
var result = await _invoker.InvokeAsync(_activity, context, () =>
|
||||
{
|
||||
invocations++;
|
||||
if (invocations < 3) throw new InvalidOperationException($"boom {invocations}");
|
||||
return Task.FromResult("recovered");
|
||||
});
|
||||
|
||||
Assert.Equal("recovered", result);
|
||||
Assert.Equal(3, invocations);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should record one attempt per retry, with the failure detail attached")]
|
||||
public async Task InvokeAsync_ActionFailsThenSucceeds_RecordsOneAttemptPerRetry()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy { MaxRetryAttempts = 3 });
|
||||
var invocations = 0;
|
||||
RecordRetryAttemptsContext? recordContext = null;
|
||||
await _recorder.RecordAsync(Arg.Do<RecordRetryAttemptsContext>(c => recordContext = c));
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, () =>
|
||||
{
|
||||
invocations++;
|
||||
if (invocations < 3) throw new InvalidOperationException($"boom {invocations}");
|
||||
return Task.FromResult("recovered");
|
||||
});
|
||||
|
||||
Assert.NotNull(recordContext);
|
||||
Assert.Same(context, recordContext.ActivityExecutionContext);
|
||||
Assert.Collection(recordContext.Attempts,
|
||||
record =>
|
||||
{
|
||||
Assert.Equal(0, record.AttemptNumber);
|
||||
Assert.Equal("boom 1", record.Details["exception"]);
|
||||
},
|
||||
record =>
|
||||
{
|
||||
Assert.Equal(1, record.AttemptNumber);
|
||||
Assert.Equal("boom 2", record.Details["exception"]);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should stamp recorded attempts with the activity and workflow identifiers")]
|
||||
public async Task InvokeAsync_ActionFailsThenSucceeds_StampsRecordsWithIdentifiers()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy());
|
||||
_identityGenerator.GenerateId().Returns("generated-id");
|
||||
RecordRetryAttemptsContext? recordContext = null;
|
||||
await _recorder.RecordAsync(Arg.Do<RecordRetryAttemptsContext>(c => recordContext = c));
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, FailOnceThenSucceed());
|
||||
|
||||
var record = Assert.Single(recordContext!.Attempts);
|
||||
Assert.Equal("generated-id", record.Id);
|
||||
Assert.Equal(context.Id, record.ActivityInstanceId);
|
||||
Assert.Equal(context.Activity.Id, record.ActivityId);
|
||||
Assert.Equal(context.WorkflowExecutionContext.Id, record.WorkflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should drop retry details whose value is null")]
|
||||
public async Task InvokeAsync_ActionFailsThenSucceeds_DropsNullDetails()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy());
|
||||
RecordRetryAttemptsContext? recordContext = null;
|
||||
await _recorder.RecordAsync(Arg.Do<RecordRetryAttemptsContext>(c => recordContext = c));
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, FailOnceThenSucceed());
|
||||
|
||||
var record = Assert.Single(recordContext!.Attempts);
|
||||
Assert.Equal(["exception"], record.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should flag that retries occurred and expose the attempt count")]
|
||||
public async Task InvokeAsync_ActionFailsThenSucceeds_FlagsRetriesAndExposesCount()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy { MaxRetryAttempts = 3 });
|
||||
|
||||
await _invoker.InvokeAsync(_activity, context, FailOnceThenSucceed());
|
||||
|
||||
Assert.True(context.GetRetriesAttemptedFlag());
|
||||
Assert.Equal(1, context.GetExtensionsMetadata()!["RetryAttemptsCount"]);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should surface the failure once the strategy stops retrying")]
|
||||
public async Task InvokeAsync_ActionAlwaysFails_ThrowsAfterExhaustingRetries()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy { MaxRetryAttempts = 2 });
|
||||
var invocations = 0;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _invoker.InvokeAsync<string>(_activity, context, () =>
|
||||
{
|
||||
invocations++;
|
||||
throw new InvalidOperationException("always boom");
|
||||
}));
|
||||
|
||||
Assert.Equal(3, invocations);
|
||||
await _recorder.DidNotReceive().RecordAsync(Arg.Any<RecordRetryAttemptsContext>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invoker should not retry an exception the strategy does not handle")]
|
||||
public async Task InvokeAsync_UnhandledExceptionType_DoesNotRetry()
|
||||
{
|
||||
var context = await CreateContextAsync();
|
||||
SetupStrategy(new TestRetryStrategy { MaxRetryAttempts = 3 });
|
||||
var invocations = 0;
|
||||
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => _invoker.InvokeAsync<string>(_activity, context, () =>
|
||||
{
|
||||
invocations++;
|
||||
throw new NotSupportedException("not transient");
|
||||
}));
|
||||
|
||||
Assert.Equal(1, invocations);
|
||||
}
|
||||
|
||||
private static Func<Task<string>> FailOnceThenSucceed()
|
||||
{
|
||||
var invocations = 0;
|
||||
|
||||
return () =>
|
||||
{
|
||||
invocations++;
|
||||
if (invocations == 1) throw new InvalidOperationException("boom 1");
|
||||
return Task.FromResult("recovered");
|
||||
};
|
||||
}
|
||||
|
||||
private void SetupStrategy(IResilienceStrategy? strategy)
|
||||
{
|
||||
_evaluator.EvaluateAsync(Arg.Any<ResilienceStrategyConfig?>(), Arg.Any<ExpressionExecutionContext>(), Arg.Any<CancellationToken>()).Returns(strategy);
|
||||
}
|
||||
|
||||
private Task<ActivityExecutionContext> CreateContextAsync() => ContextFactory.CreateAsync(_activity);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
|
||||
internal static class ContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a real <see cref="ActivityExecutionContext"/> for <paramref name="activity"/> without executing it.
|
||||
/// </summary>
|
||||
public static Task<ActivityExecutionContext> CreateAsync(IActivity activity, Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var fixture = new ActivityTestFixture(activity);
|
||||
|
||||
if (configureServices != null)
|
||||
fixture.ConfigureServices(configureServices);
|
||||
|
||||
return fixture.BuildAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// A strategy that configures a real Polly retry pipeline with zero delay, so tests can exercise actual retry behavior.
|
||||
/// </summary>
|
||||
internal class TestRetryStrategy : IResilienceStrategy
|
||||
{
|
||||
public string Id { get; set; } = "test-retry";
|
||||
public string DisplayName { get; set; } = "Test Retry";
|
||||
public int MaxRetryAttempts { get; set; } = 2;
|
||||
|
||||
public Task ConfigurePipeline<T>(ResiliencePipelineBuilder<T> pipelineBuilder, ResilienceContext context)
|
||||
{
|
||||
pipelineBuilder.AddRetry(new RetryStrategyOptions<T>
|
||||
{
|
||||
MaxRetryAttempts = MaxRetryAttempts,
|
||||
Delay = TimeSpan.Zero,
|
||||
BackoffType = DelayBackoffType.Constant,
|
||||
ShouldHandle = new PredicateBuilder<T>().Handle<InvalidOperationException>()
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A strategy that adds nothing to the pipeline, used to verify pass-through behavior and polymorphic serialization.
|
||||
/// </summary>
|
||||
internal class TestNoopStrategy : IResilienceStrategy
|
||||
{
|
||||
public string Id { get; set; } = "test-noop";
|
||||
public string DisplayName { get; set; } = "Test Noop";
|
||||
public TestStrategyFlavor Flavor { get; set; }
|
||||
|
||||
public Task ConfigurePipeline<T>(ResiliencePipelineBuilder<T> pipelineBuilder, ResilienceContext context) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal enum TestStrategyFlavor
|
||||
{
|
||||
Plain,
|
||||
Fancy
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using Elsa.Resilience.Models;
|
||||
using Elsa.Workflows;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// A resilient activity that reports one detail per retry attempt, plus one detail that is always null so that
|
||||
/// tests can assert null details are dropped rather than recorded.
|
||||
/// </summary>
|
||||
internal class TestResilientActivity : CodeActivity, IResilientActivity
|
||||
{
|
||||
public IDictionary<string, string?> CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt) => new Dictionary<string, string?>
|
||||
{
|
||||
["exception"] = attempt.Exception?.Message,
|
||||
["never-recorded"] = null
|
||||
};
|
||||
|
||||
protected override void Execute(ActivityExecutionContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using Elsa.Resilience.Entities;
|
||||
using Elsa.Resilience.Models;
|
||||
|
||||
namespace Elsa.Resilience.Core.UnitTests;
|
||||
|
||||
public class VoidRetryAttemptServiceTests
|
||||
{
|
||||
[Fact(DisplayName = "Void reader should return an empty page")]
|
||||
public async Task ReadAttemptsAsync_ReturnsEmptyPage()
|
||||
{
|
||||
var page = await VoidRetryAttemptReader.Instance.ReadAttemptsAsync("activity-instance-1");
|
||||
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Equal(0, page.TotalCount);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Void reader instance should be a singleton")]
|
||||
public void ReaderInstance_IsSingleton()
|
||||
{
|
||||
Assert.Same(VoidRetryAttemptReader.Instance, VoidRetryAttemptReader.Instance);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Void recorder should discard records without faulting")]
|
||||
public async Task RecordAsync_DiscardsRecords()
|
||||
{
|
||||
var context = new RecordRetryAttemptsContext(null!, [new RetryAttemptRecord()], CancellationToken.None);
|
||||
|
||||
await VoidRetryAttemptRecorder.Instance.RecordAsync(context);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Void recorder instance should be a singleton")]
|
||||
public void RecorderInstance_IsSingleton()
|
||||
{
|
||||
Assert.Same(VoidRetryAttemptRecorder.Instance, VoidRetryAttemptRecorder.Instance);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue