using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.InboundAPI; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// ScadaBridge#75: an inbound method whose script fails to compile answers callers with a /// generic message and its real diagnostic used to reach only the central log. The /// management surface now returns the diagnostics (ValidateApiMethod), shows the /// node's compile state on GetApiMethod, and can clear a stale failure without a /// full-replace update (RecompileApiMethod). /// public class ApiMethodCompileDiagnosticsTests : TestKit { private const string BrokenScript = "return doesNotExist;"; private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction(); private readonly IInboundApiRepository _apiRepo = Substitute.For(); private readonly IAuditService _audit = Substitute.For(); private readonly InboundScriptExecutor _executor = new(NullLogger.Instance, Substitute.For()); private readonly ApiMethod _method = new("Broken", BrokenScript) { Id = 5, TimeoutSeconds = 10 }; // The other central node, reached only through the peer invalidation (ScadaBridge#103). private readonly InboundScriptExecutor _peerExecutor = new(NullLogger.Instance, Substitute.For()); private readonly ApiMethodCache _peerMethodCache = new(TimeSpan.FromHours(1)); private readonly PeerCacheInvalidationPublisher _publisher; /// Initializes the harness with a real executor and a stubbed repository. public ApiMethodCompileDiagnosticsTests() { _services.AddScoped(_ => _apiRepo); _services.AddScoped(_ => _audit); _services.AddSingleton(_executor); _publisher = new PeerCacheInvalidationPublisher( new InboundApiCacheInvalidationHandler(_peerExecutor, _peerMethodCache)); _services.AddSingleton(_publisher); _apiRepo.GetApiMethodByIdAsync(5, Arg.Any()).Returns(_method); _apiRepo.SaveChangesAsync(Arg.Any()).Returns(1); } private JsonElement Send(object command, string role = "Designer") { var actor = Sys.ActorOf(Props.Create(() => new ManagementActor( _services.BuildServiceProvider(), NullLogger.Instance))); actor.Tell(new ManagementEnvelope( new AuthenticatedUser("designer", "Designer", new[] { role }, Array.Empty()), command, Guid.NewGuid().ToString("N"))); var response = ExpectMsg(TimeSpan.FromSeconds(30)); return JsonDocument.Parse(response.JsonData).RootElement.Clone(); } /// /// After the data plane recorded a compile failure, GetApiMethod reports it with the real /// compiler diagnostic, alongside the unchanged entity fields. /// [Fact] public void Get_AfterACompileFailure_ReportsKnownBadWithTheDiagnostic() { Assert.False(_executor.CompileAndRegister(_method)); var result = Send(new GetApiMethodCommand(5), role: "Viewer"); Assert.Equal("Broken", result.GetProperty("name").GetString()); Assert.Equal(BrokenScript, result.GetProperty("script").GetString()); Assert.True(result.GetProperty("isKnownBad").GetBoolean()); Assert.False(result.GetProperty("isCompiled").GetBoolean()); Assert.Contains("doesNotExist", result.GetProperty("lastCompileError").GetString()); } /// Validate returns the diagnostics and leaves the handler cache untouched. [Fact] public void Validate_ReturnsDiagnostics_WithoutRecordingAFailure() { var result = Send(new ValidateApiMethodCommand(5)); Assert.False(result.GetProperty("success").GetBoolean()); Assert.Contains(result.GetProperty("errors").EnumerateArray(), e => e.GetString()!.Contains("doesNotExist", StringComparison.Ordinal)); var state = _executor.GetCompileState("Broken", BrokenScript); Assert.False(state.IsKnownBad); Assert.False(state.IsCompiled); } /// /// A failure recorded for unchanged script text is cleared by a recompile once the cause /// outside the script is gone — here modelled by the stored row now compiling — without /// re-sending the entity. The recompile is audited. /// [Fact] public void Recompile_ClearsAStaleFailure_AndIsAudited() { Assert.False(_executor.CompileAndRegister(_method)); _method.Script = "return 1;"; var result = Send(new RecompileApiMethodCommand(5)); Assert.True(result.GetProperty("success").GetBoolean()); Assert.Empty(result.GetProperty("errors").EnumerateArray()); var state = _executor.GetCompileState("Broken", "return 1;"); Assert.True(state.IsCompiled); Assert.False(state.IsKnownBad); _audit.Received(1).LogAsync("designer", "Recompile", "ApiMethod", "5", "Broken", Arg.Any(), Arg.Any()); } /// A recompile of a still-broken script records the new diagnostics. [Fact] public void Recompile_StillBroken_RecordsTheDiagnostics() { var result = Send(new RecompileApiMethodCommand(5)); Assert.False(result.GetProperty("success").GetBoolean()); var state = _executor.GetCompileState("Broken", BrokenScript); Assert.True(state.IsKnownBad); Assert.Contains("doesNotExist", state.LastCompileError); } /// /// ScadaBridge#103: compile state is per node, so a recompile used to clear the known-bad /// record only on the node that answered — the peer kept refusing the unchanged script with /// SCRIPT_COMPILE_FAILED until it restarted. The recompile now drops the peer's record (and /// its cached row), so the peer compiles the current text on its next request. /// /// A task that represents the asynchronous operation. [Fact] public async Task Recompile_ClearsTheKnownBadRecordOnThePeerNode() { Assert.False(_peerExecutor.CompileAndRegister(_method)); Assert.True(_peerExecutor.GetCompileState("Broken", BrokenScript).IsKnownBad); // positive control await _peerMethodCache.GetOrFetchAsync("Broken", _ => Task.FromResult(_method), CancellationToken.None); Send(new RecompileApiMethodCommand(5)); Assert.False(_peerExecutor.GetCompileState("Broken", BrokenScript).IsKnownBad); var refetched = false; await _peerMethodCache.GetOrFetchAsync("Broken", _ => { refetched = true; return Task.FromResult(_method); }, CancellationToken.None); Assert.True(refetched); Assert.Equal(new[] { new InboundApiMethodInvalidated("Broken") }, _publisher.Published); } /// Negative control: validate changes nothing anywhere, so it tells the peer nothing. [Fact] public void Validate_DoesNotTouchThePeer() { Assert.False(_peerExecutor.CompileAndRegister(_method)); Send(new ValidateApiMethodCommand(5)); Assert.True(_peerExecutor.GetCompileState("Broken", BrokenScript).IsKnownBad); Assert.Empty(_publisher.Published); } /// Validate and recompile are Designer operations, like UpdateApiMethod. /// The command name to send. [Theory] [InlineData("Validate")] [InlineData("Recompile")] public void ValidateAndRecompile_RequireDesigner(string command) { var actor = Sys.ActorOf(Props.Create(() => new ManagementActor( _services.BuildServiceProvider(), NullLogger.Instance))); object message = command == "Validate" ? new ValidateApiMethodCommand(5) : new RecompileApiMethodCommand(5); actor.Tell(new ManagementEnvelope( new AuthenticatedUser("viewer", "Viewer", new[] { "Viewer" }, Array.Empty()), message, Guid.NewGuid().ToString("N"))); ExpectMsg(TimeSpan.FromSeconds(10)); } }