using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Resilience; namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience; [Trait("Category", "Unit")] public sealed class CapabilityInvokerTests { private static CapabilityInvoker MakeInvoker( DriverResiliencePipelineBuilder builder, DriverResilienceOptions options) => new(builder, "drv-test", () => options); [Fact] public async Task Read_ReturnsValue_FromCallSite() { var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A }); var result = await invoker.ExecuteAsync( DriverCapability.Read, "host-1", _ => ValueTask.FromResult(42), CancellationToken.None); result.ShouldBe(42); } [Fact] public async Task Read_Retries_OnTransientFailure() { var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A }); var attempts = 0; var result = await invoker.ExecuteAsync( DriverCapability.Read, "host-1", async _ => { attempts++; if (attempts < 2) throw new InvalidOperationException("transient"); await Task.Yield(); return "ok"; }, CancellationToken.None); result.ShouldBe("ok"); attempts.ShouldBe(2); } [Fact] public async Task Write_NonIdempotent_DoesNotRetry_EvenWhenPolicyHasRetries() { var options = new DriverResilienceOptions { Tier = DriverTier.A, CapabilityPolicies = new Dictionary { [DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5), }, }; var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), options); var attempts = 0; await Should.ThrowAsync(async () => await invoker.ExecuteWriteAsync( "host-1", isIdempotent: false, async _ => { attempts++; await Task.Yield(); throw new InvalidOperationException("boom"); #pragma warning disable CS0162 return 0; #pragma warning restore CS0162 }, CancellationToken.None)); attempts.ShouldBe(1, "non-idempotent write must never replay"); } [Fact] public async Task Write_Idempotent_Retries_WhenPolicyHasRetries() { var options = new DriverResilienceOptions { Tier = DriverTier.A, CapabilityPolicies = new Dictionary { [DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5), }, }; var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), options); var attempts = 0; var result = await invoker.ExecuteWriteAsync( "host-1", isIdempotent: true, async _ => { attempts++; if (attempts < 2) throw new InvalidOperationException("transient"); await Task.Yield(); return "ok"; }, CancellationToken.None); result.ShouldBe("ok"); attempts.ShouldBe(2); } [Fact] public async Task Write_Default_DoesNotRetry_WhenPolicyHasZeroRetries() { // Tier A Write default is RetryCount=0. Even isIdempotent=true shouldn't retry // because the policy says not to. var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A }); var attempts = 0; await Should.ThrowAsync(async () => await invoker.ExecuteWriteAsync( "host-1", isIdempotent: true, async _ => { attempts++; await Task.Yield(); throw new InvalidOperationException("boom"); #pragma warning disable CS0162 return 0; #pragma warning restore CS0162 }, CancellationToken.None)); attempts.ShouldBe(1, "tier-A default for Write is RetryCount=0"); } [Fact] public async Task Execute_HonorsDifferentHosts_Independently() { var builder = new DriverResiliencePipelineBuilder(); var invoker = MakeInvoker(builder, new DriverResilienceOptions { Tier = DriverTier.A }); await invoker.ExecuteAsync(DriverCapability.Read, "host-a", _ => ValueTask.FromResult(1), CancellationToken.None); await invoker.ExecuteAsync(DriverCapability.Read, "host-b", _ => ValueTask.FromResult(2), CancellationToken.None); builder.CachedPipelineCount.ShouldBe(2); } }