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); } /// /// Core-009 regression: ExecuteWriteAsync's non-idempotent branch must snapshot /// _optionsAccessor exactly once per call. Calling it multiple times allocates /// redundant options objects on the per-write hot path and creates a consistency hazard /// where an Admin edit mid-call could observe two different snapshots. /// [Fact] public async Task ExecuteWriteAsync_NonIdempotent_Snapshots_Options_Once_Per_Call() { var options = new DriverResilienceOptions { Tier = DriverTier.A, CapabilityPolicies = new Dictionary { [DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5), }, }; var accessorCalls = 0; var invoker = new CapabilityInvoker( new DriverResiliencePipelineBuilder(), "drv-test", () => { Interlocked.Increment(ref accessorCalls); return options; }); await invoker.ExecuteWriteAsync( "host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None); accessorCalls.ShouldBe(1, "ExecuteWriteAsync's non-idempotent branch must capture the options snapshot exactly once per call"); } /// /// Core-009 regression — companion consistency assertion: the non-idempotent branch must /// not observe two different option snapshots even if the accessor's returned value changes /// between calls (simulating an Admin edit landing mid-flight). With a single snapshot the /// two derived values (with base + Resolve(Write)) come from the same options /// instance. /// [Fact] public async Task ExecuteWriteAsync_NonIdempotent_Uses_Consistent_Options_Snapshot() { var a = new DriverResilienceOptions { Tier = DriverTier.A }; var b = new DriverResilienceOptions { Tier = DriverTier.B }; var alternating = new[] { a, b, a, b }.AsEnumerable().GetEnumerator(); var invoker = new CapabilityInvoker( new DriverResiliencePipelineBuilder(), "drv-test", () => { alternating.MoveNext(); return alternating.Current; }); // If options is read twice, the with-expression and Resolve(Write) come from // different tier tables (A then B) — the resulting one-entry dictionary is // inconsistent with the snapshot used for the rest of the options. Single-snapshot // semantics guarantee the call sees a coherent view. await Should.NotThrowAsync(async () => await invoker.ExecuteWriteAsync( "host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None)); } }