using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// Arch-review R2 N1: the Inbound API must be a REAL subscriber of /// IScriptArtifactChangeBus — a bundle-import publish must invalidate a cached /// handler immediately, without waiting for the per-request revision-check /// self-heal (which remains the correctness fallback per the invalidation /// contract's normative semantics). /// public class ScriptArtifactChangeSubscriberTests { /// Minimal in-test bus: records subscriptions, delivers synchronously. private sealed class RecordingBus : IScriptArtifactChangeBus { private readonly List> _handlers = new(); public int SubscriberCount => _handlers.Count; public void Publish(ScriptArtifactsChanged notification) { foreach (var handler in _handlers.ToArray()) handler(notification); } public IDisposable Subscribe(Action handler) { _handlers.Add(handler); return new Token(() => _handlers.Remove(handler)); } private sealed class Token(Action dispose) : IDisposable { public void Dispose() => dispose(); } } private readonly InboundScriptExecutor _executor = new( NullLogger.Instance, Substitute.For()); private readonly RecordingBus _bus = new(); private readonly RouteHelper _route = new( Substitute.For(), Substitute.For()); private ScriptArtifactChangeSubscriber CreateSubscriber(IScriptArtifactChangeBus? bus) => new(_executor, NullLogger.Instance, bus); private Task Run(ApiMethod m) => _executor.ExecuteAsync( m, new Dictionary(), _route, TimeSpan.FromSeconds(10)); private static ScriptArtifactsChanged ApiMethodChanged(params string[] names) => new(ScriptArtifactKinds.ApiMethod, names, "BundleImport", DateTimeOffset.UtcNow); [Fact] public async Task ApiMethodPublish_InvalidatesCachedHandler_WithoutRevisionCheckSelfHeal() { var subscriber = CreateSubscriber(_bus); await subscriber.StartAsync(CancellationToken.None); // Pin a SCRIPT-LESS handler: the RegisterHandler seam is exempt from the // per-request revision check, so the ONLY mechanism that can stop it // serving is an explicit InvalidateMethod — exactly what the bus must trigger. _executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; }); var row = new ApiMethod("m", "return 1;") { Id = 1, TimeoutSeconds = 10 }; var before = await Run(row); Assert.Contains("99", before.ResultJson); // pinned handler serves; no self-heal fires _bus.Publish(ApiMethodChanged("m")); var after = await Run(row); Assert.True(after.Success); Assert.Contains("1", after.ResultJson); // recompiled from the fresh row — invalidation, not self-heal } [Fact] public async Task ApiMethodPublish_PurgesKnownBadRecord() { var subscriber = CreateSubscriber(_bus); await subscriber.StartAsync(CancellationToken.None); var broken = new ApiMethod("bad", "%%% not C# %%%") { Id = 2, TimeoutSeconds = 10 }; Assert.False(_executor.CompileAndRegister(broken)); Assert.Equal(1, _executor.KnownBadMethodCount); _bus.Publish(ApiMethodChanged("bad")); Assert.Equal(0, _executor.KnownBadMethodCount); } [Fact] public async Task NonApiMethodKinds_AreIgnored() { var subscriber = CreateSubscriber(_bus); await subscriber.StartAsync(CancellationToken.None); _executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; }); var row = new ApiMethod("m", "return 1;") { Id = 3, TimeoutSeconds = 10 }; _bus.Publish(new ScriptArtifactsChanged( ScriptArtifactKinds.SharedScript, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow)); _bus.Publish(new ScriptArtifactsChanged( ScriptArtifactKinds.Template, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow)); var result = await Run(row); Assert.Contains("99", result.ResultJson); // pinned handler untouched } [Fact] public async Task StopAsync_Unsubscribes() { var subscriber = CreateSubscriber(_bus); await subscriber.StartAsync(CancellationToken.None); Assert.Equal(1, _bus.SubscriberCount); await subscriber.StopAsync(CancellationToken.None); Assert.Equal(0, _bus.SubscriberCount); } [Fact] public async Task NullBus_NoOps() { // Site roles / plain test hosts have no bus registered — the hosted // service must start and stop cleanly (the revision check alone applies). var subscriber = CreateSubscriber(bus: null); await subscriber.StartAsync(CancellationToken.None); await subscriber.StopAsync(CancellationToken.None); } }