using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// Arch-review S1: the compiled-handler cache must be revision-checked against the /// freshly-fetched ApiMethod.Script so a standby node (whose cache was populated at /// its own startup) never serves a stale delegate after failover, and a method fixed /// out-of-band is never stuck behind a stale known-bad record. /// public class InboundScriptExecutorStalenessTests { private readonly InboundScriptExecutor _executor; private readonly RouteHelper _route; public InboundScriptExecutorStalenessTests() { _executor = new InboundScriptExecutor( NullLogger.Instance, Substitute.For()); _route = new RouteHelper( Substitute.For(), Substitute.For()); } private Task Run(ApiMethod m) => _executor.ExecuteAsync( m, new Dictionary(), _route, TimeSpan.FromSeconds(10)); [Fact] public async Task UpdatedScript_RecompilesInsteadOfServingStaleHandler() { // Simulates the failover scenario: this node compiled v1 at startup... var v1 = new ApiMethod("stale", "return 1;") { Id = 1, TimeoutSeconds = 10 }; Assert.True(_executor.CompileAndRegister(v1)); var r1 = await Run(v1); Assert.Contains("1", r1.ResultJson); // ...the active node updated the method (this node never heard about it); // the per-request DB fetch hands ExecuteAsync the NEW row. var v2 = new ApiMethod("stale", "return 2;") { Id = 1, TimeoutSeconds = 10 }; var r2 = await Run(v2); Assert.True(r2.Success); Assert.Contains("2", r2.ResultJson); // old cache would have returned 1 } [Fact] public async Task KnownBadMethod_FixedScript_CompilesInsteadOfFastFailing() { // The mirror-image variant: broken at this node's startup... var broken = new ApiMethod("fixme", "%%% not C# %%%") { Id = 2, TimeoutSeconds = 10 }; Assert.False(_executor.CompileAndRegister(broken)); var r1 = await Run(broken); Assert.False(r1.Success); // ...fixed via the management path on the OTHER node; this node's fresh // DB fetch carries the fixed script. var fixedMethod = new ApiMethod("fixme", "return 42;") { Id = 2, TimeoutSeconds = 10 }; var r2 = await Run(fixedMethod); Assert.True(r2.Success); Assert.Contains("42", r2.ResultJson); } [Fact] public async Task KnownBadMethod_SameScript_StillFastFails() { var broken = new ApiMethod("stillbad", "%%% not C# %%%") { Id = 3, TimeoutSeconds = 10 }; Assert.False(_executor.CompileAndRegister(broken)); // Same script text → the fast-fail record must hold (no per-request Roslyn). var again = new ApiMethod("stillbad", "%%% not C# %%%") { Id = 3, TimeoutSeconds = 10 }; var r = await Run(again); Assert.False(r.Success); Assert.Contains("Script compilation failed", r.ErrorMessage); } [Fact] public async Task TestSeamRegisterHandler_WithoutScript_IsNotRevisionChecked() { // RegisterHandler (script-less test seam) pins the handler regardless of row content. var m = new ApiMethod("pinned", "return 0;") { Id = 4, TimeoutSeconds = 10 }; _executor.RegisterHandler("pinned", async _ => { await Task.CompletedTask; return 99; }); var r = await Run(m); Assert.True(r.Success); Assert.Contains("99", r.ResultJson); } [Fact] public async Task InvalidateMethod_DropsHandlerAndKnownBadRecord() { var good = new ApiMethod("inv", "return 5;") { Id = 5, TimeoutSeconds = 10 }; Assert.True(_executor.CompileAndRegister(good)); var broken = new ApiMethod("invbad", "%%%") { Id = 6, TimeoutSeconds = 10 }; Assert.False(_executor.CompileAndRegister(broken)); _executor.InvalidateMethod("inv"); _executor.InvalidateMethod("invbad"); Assert.Equal(0, _executor.KnownBadMethodCount); // After invalidation the method lazily recompiles from the row — still serves. var r = await Run(good); Assert.True(r.Success); } }