using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; 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.Management; using ZB.MOM.WW.ScadaBridge.InboundAPI; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// Review 2026-10-21 05/M1: an API-method save used to compile the script twice (a /// ValidateCompilation pre-pass, then CompileAndRegister), and each compile /// pins a non-collectible assembly. A save must compile exactly once — including when the /// registration is deferred until after the configuration transaction commits. /// public class ApiMethodSingleCompileTests : TestKit { private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction(); private readonly IInboundApiRepository _apiRepo = Substitute.For(); private readonly CompileCountingLogger _logger = new(); private readonly CollectingCommitActions _commitActions = new(); /// Initializes the harness with a real executor whose compiles are counted. public ApiMethodSingleCompileTests() { _services.AddScoped(_ => _apiRepo); _services.AddScoped(_ => Substitute.For()); _services.AddSingleton(new InboundScriptExecutor(_logger, Substitute.For())); _apiRepo.SaveChangesAsync(Arg.Any()).Returns(1); } private IActorRef CreateActor() => Sys.ActorOf(Props.Create(() => new ManagementActor( _services.BuildServiceProvider(), NullLogger.Instance))); private static ManagementEnvelope Envelope(object command) => new(new AuthenticatedUser("designer", "Designer", new[] { "Designer" }, Array.Empty()), command, Guid.NewGuid().ToString("N")); /// A compiling create without deferred commit actions compiles once. [Fact] public void Create_CompilingScript_CompilesOnce() { CreateActor().Tell(Envelope(new CreateApiMethodCommand("Once", "return 1;", 30, null, null))); var response = ExpectMsg(TimeSpan.FromSeconds(30)); Assert.Equal(1, _logger.CompileCount); Assert.DoesNotContain("failed to compile", response.JsonData); } /// /// With the registration deferred to post-commit, the warning is still derived before the /// reply and the queued publish reuses the compiled result instead of recompiling. /// /// A task that represents the asynchronous operation. [Fact] public async Task Update_DeferredRegistration_CompilesOnce_AndStillWarns() { _services.AddScoped(_ => _commitActions); var existing = new ApiMethod("M", "return 1;") { Id = 7, TimeoutSeconds = 10 }; _apiRepo.GetApiMethodByIdAsync(7, Arg.Any()).Returns(existing); CreateActor().Tell(Envelope(new UpdateApiMethodCommand(7, "return doesNotExist;", 10, null, null))); var response = ExpectMsg(TimeSpan.FromSeconds(30)); Assert.Contains("failed to compile", response.JsonData); foreach (var action in _commitActions.Actions) await action(); Assert.NotEmpty(_commitActions.Actions); Assert.Equal(0, _logger.CompileCount); // the broken script never reaches "compiled" Assert.Equal(1, _logger.CompileFailureCount); } /// A compiling update with deferred registration compiles once across reply + publish. /// A task that represents the asynchronous operation. [Fact] public async Task Update_DeferredRegistration_CompilingScript_CompilesOnce() { _services.AddScoped(_ => _commitActions); var existing = new ApiMethod("M2", "return 1;") { Id = 8, TimeoutSeconds = 10 }; _apiRepo.GetApiMethodByIdAsync(8, Arg.Any()).Returns(existing); CreateActor().Tell(Envelope(new UpdateApiMethodCommand(8, "return 2;", 10, null, null))); ExpectMsg(TimeSpan.FromSeconds(30)); foreach (var action in _commitActions.Actions) await action(); Assert.Equal(1, _logger.CompileCount); } private sealed class CollectingCommitActions : IConfigurationCommitActions { public List> Actions { get; } = new(); public void Add(Func action) => Actions.Add(action); } private sealed class CompileCountingLogger : ILogger { private int _count; private int _failures; public int CompileCount => _count; public int CompileFailureCount => _failures; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var text = formatter(state, exception); if (text.EndsWith("script compiled", StringComparison.Ordinal)) Interlocked.Increment(ref _count); else if (text.Contains("script compilation failed", StringComparison.Ordinal)) Interlocked.Increment(ref _failures); } } }