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); /// Verifies that the capability invoker returns the value from the call site. [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); } /// Verifies that the capability invoker retries on transient failures. [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); } /// Verifies that non-idempotent writes do not retry even when the policy has retries configured. [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"); } /// Verifies that idempotent writes retry when the policy has retries configured. [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); } /// Verifies that writes do not retry when the policy has zero retries configured. [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"); } /// Verifies that different hosts are honored independently in the resilience pipeline. [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"); } /// /// S-8 residual: the non-idempotent write arm must record tracker in-flight accounting (start + /// complete) on the caller's host, exactly like the idempotent arms — the previous arm skipped it. /// [Fact] public async Task NonIdempotentWrite_RecordsTrackerStartAndComplete() { var tracker = new DriverResilienceStatusTracker(); var invoker = new CapabilityInvoker( new DriverResiliencePipelineBuilder(), "drv-test", () => new DriverResilienceOptions { Tier = DriverTier.A }, statusTracker: tracker); await invoker.ExecuteWriteAsync( "host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None); var snap = tracker.TryGet("drv-test", "host-1"); snap.ShouldNotBeNull("the non-idempotent write arm must record tracker accounting on the caller's host"); snap!.CurrentInFlight.ShouldBe(0, "in-flight must return to 0 after the write completes"); } /// /// 01/P-4: the non-idempotent write arm must build the no-retry options snapshot ONCE per invoker /// lifetime (not per call). Two writes to the same host reuse the cached snapshot — the options /// accessor is invoked at most once and only one pipeline is cached. /// [Fact] public async Task NonIdempotentWrite_ReusesCachedNoRetryOptions() { var builder = new DriverResiliencePipelineBuilder(); var accessorCalls = 0; var invoker = new CapabilityInvoker( builder, "drv-test", () => { Interlocked.Increment(ref accessorCalls); return new DriverResilienceOptions { Tier = DriverTier.A }; }); await invoker.ExecuteWriteAsync("host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None); await invoker.ExecuteWriteAsync("host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None); accessorCalls.ShouldBeLessThanOrEqualTo(1, "the no-retry snapshot must be cached across calls"); builder.CachedPipelineCount.ShouldBe(1, "two writes to the same host share one cached no-retry pipeline"); } /// /// R2-10: a successful capability call must reset ConsecutiveFailures so the counter /// is a true "consecutive" gauge — nonzero only during an active retry storm, not stuck at the /// last blip's count forever. Previously only the breaker's OnClosed reset it, so a /// transient 2-retry blip that never opened the breaker read "2 failures" indefinitely. /// [Fact] public async Task ExecuteAsync_OnSuccess_ResetsConsecutiveFailures() { var tracker = new DriverResilienceStatusTracker(); tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow); tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow); tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(2, "seeded failures"); var invoker = new CapabilityInvoker( new DriverResiliencePipelineBuilder(), "drv-test", () => new DriverResilienceOptions { Tier = DriverTier.A }, statusTracker: tracker); await invoker.ExecuteAsync( DriverCapability.Read, "host-1", _ => ValueTask.FromResult(1), CancellationToken.None); tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(0, "a successful capability call must clear the consecutive-failure counter"); } /// /// R2-10 companion: a successful non-idempotent write must also reset the consecutive-failure /// counter on the caller's host (the write arm has its own success path). /// [Fact] public async Task ExecuteWriteAsync_NonIdempotent_OnSuccess_ResetsConsecutiveFailures() { var tracker = new DriverResilienceStatusTracker(); tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow); tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(1, "seeded failure"); var invoker = new CapabilityInvoker( new DriverResiliencePipelineBuilder(), "drv-test", () => new DriverResilienceOptions { Tier = DriverTier.A }, statusTracker: tracker); await invoker.ExecuteWriteAsync( "host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None); tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(0, "a successful non-idempotent write must clear the consecutive-failure counter"); } /// /// 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)); } }