diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/ActivityExecutionExtensionsTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/ActivityExecutionExtensionsTests.cs new file mode 100644 index 000000000..6bca8c3eb --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/ActivityExecutionExtensionsTests.cs @@ -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("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); + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/Elsa.Resilience.Core.UnitTests.csproj b/test/unit/Elsa.Resilience.Core.UnitTests/Elsa.Resilience.Core.UnitTests.csproj index f86df2cad..a1369cd05 100644 --- a/test/unit/Elsa.Resilience.Core.UnitTests/Elsa.Resilience.Core.UnitTests.csproj +++ b/test/unit/Elsa.Resilience.Core.UnitTests/Elsa.Resilience.Core.UnitTests.csproj @@ -2,7 +2,7 @@ [Elsa.Resilience.Core]* - 49 + 90 diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceCategoryAttributeTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceCategoryAttributeTests.cs new file mode 100644 index 000000000..512d3154e --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceCategoryAttributeTests.cs @@ -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() + .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() + .Single(); + + Assert.Equal(AttributeTargets.Class, usage.ValidOn); + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceServiceCollectionExtensionsTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..b0473a1a2 --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceServiceCollectionExtensionsTests.cs @@ -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>().Value; + + [Fact(DisplayName = "AddResilienceStrategy should register the strategy type")] + public void AddResilienceStrategy_RegistersType() + { + _services.AddResilienceStrategy(); + + Assert.Equal([typeof(TestRetryStrategy)], GetOptions().StrategyTypes); + } + + [Fact(DisplayName = "AddResilienceStrategy should accumulate across calls")] + public void AddResilienceStrategy_CalledTwice_RegistersBothTypes() + { + _services.AddResilienceStrategy(); + _services.AddResilienceStrategy(); + + 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); + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceStrategyConfigTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceStrategyConfigTests.cs new file mode 100644 index 000000000..f8c8847ff --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceStrategyConfigTests.cs @@ -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(() => ResilienceStrategyConfig.Deserialize(node)); + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceStrategySerializerTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceStrategySerializerTests.cs new file mode 100644 index 000000000..133e4bc81 --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/ResilienceStrategySerializerTests.cs @@ -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(_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(_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(_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(s).Id), + s => Assert.Equal("second", Assert.IsType(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(() => serializer.Serialize(new TestRetryStrategy())); + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/ResilientActivityInvokerTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/ResilientActivityInvokerTests.cs new file mode 100644 index 000000000..18c72b8b8 --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/ResilientActivityInvokerTests.cs @@ -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(); + private readonly IRetryAttemptRecorder _recorder = Substitute.For(); + private readonly IIdentityGenerator _identityGenerator = Substitute.For(); + 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("ResilienceStrategy")); + Assert.False(context.GetRetriesAttemptedFlag()); + await _recorder.DidNotReceive().RecordAsync(Arg.Any()); + } + + [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(c => c.Mode == ResilienceStrategyConfigMode.Identifier && c.StrategyId == "my-strategy"), + context.ExpressionExecutionContext, + Arg.Any()); + } + + [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()); + } + + [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("ResilienceStrategy"); + Assert.NotNull(model); + Assert.Equal(nameof(TestRetryStrategy), model["$type"]!.GetValue()); + Assert.Equal("applied-strategy", model["id"]!.GetValue()); + } + + [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()); + 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(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(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(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(() => _invoker.InvokeAsync(_activity, context, () => + { + invocations++; + throw new InvalidOperationException("always boom"); + })); + + Assert.Equal(3, invocations); + await _recorder.DidNotReceive().RecordAsync(Arg.Any()); + } + + [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(() => _invoker.InvokeAsync(_activity, context, () => + { + invocations++; + throw new NotSupportedException("not transient"); + })); + + Assert.Equal(1, invocations); + } + + private static Func> 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(), Arg.Any(), Arg.Any()).Returns(strategy); + } + + private Task CreateContextAsync() => ContextFactory.CreateAsync(_activity); +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/ContextFactory.cs b/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/ContextFactory.cs new file mode 100644 index 000000000..28a5a4b26 --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/ContextFactory.cs @@ -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 +{ + /// + /// Builds a real for without executing it. + /// + public static Task CreateAsync(IActivity activity, Action? configureServices = null) + { + var fixture = new ActivityTestFixture(activity); + + if (configureServices != null) + fixture.ConfigureServices(configureServices); + + return fixture.BuildAsync(); + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/TestResilienceStrategies.cs b/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/TestResilienceStrategies.cs new file mode 100644 index 000000000..703d75546 --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/TestResilienceStrategies.cs @@ -0,0 +1,45 @@ +using Polly; +using Polly.Retry; + +namespace Elsa.Resilience.Core.UnitTests.TestHelpers; + +/// +/// A strategy that configures a real Polly retry pipeline with zero delay, so tests can exercise actual retry behavior. +/// +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(ResiliencePipelineBuilder pipelineBuilder, ResilienceContext context) + { + pipelineBuilder.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = MaxRetryAttempts, + Delay = TimeSpan.Zero, + BackoffType = DelayBackoffType.Constant, + ShouldHandle = new PredicateBuilder().Handle() + }); + + return Task.CompletedTask; + } +} + +/// +/// A strategy that adds nothing to the pipeline, used to verify pass-through behavior and polymorphic serialization. +/// +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(ResiliencePipelineBuilder pipelineBuilder, ResilienceContext context) => Task.CompletedTask; +} + +internal enum TestStrategyFlavor +{ + Plain, + Fancy +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/TestResilientActivity.cs b/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/TestResilientActivity.cs new file mode 100644 index 000000000..1df1fd03c --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/TestHelpers/TestResilientActivity.cs @@ -0,0 +1,21 @@ +using Elsa.Resilience.Models; +using Elsa.Workflows; + +namespace Elsa.Resilience.Core.UnitTests.TestHelpers; + +/// +/// 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. +/// +internal class TestResilientActivity : CodeActivity, IResilientActivity +{ + public IDictionary CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt) => new Dictionary + { + ["exception"] = attempt.Exception?.Message, + ["never-recorded"] = null + }; + + protected override void Execute(ActivityExecutionContext context) + { + } +} diff --git a/test/unit/Elsa.Resilience.Core.UnitTests/VoidRetryAttemptServiceTests.cs b/test/unit/Elsa.Resilience.Core.UnitTests/VoidRetryAttemptServiceTests.cs new file mode 100644 index 000000000..4004786af --- /dev/null +++ b/test/unit/Elsa.Resilience.Core.UnitTests/VoidRetryAttemptServiceTests.cs @@ -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); + } +}