using Akka.Actor; using Microsoft.Extensions.Logging.Abstractions; using Moq; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// M2.12 (#25): recursion-limit violations at CallScript and /// CallShared must emit a script Error site event via /// in addition to the existing /// ILogger.LogError + throw. /// public class RecursionLimitSiteEventTests { private const string InstanceName = "test-instance"; private const string SourceScript = "ScriptActor:OnTick"; /// /// Build a wired at the recursion /// limit (currentCallDepth == maxCallDepth) so the very next /// CallScript or CallShared call trips the guard. /// private static ScriptRuntimeContext CreateContextAtLimit( ISiteEventLogger siteEventLogger, int maxCallDepth = 3) { var compilationService = new ScriptCompilationService( NullLogger.Instance); var sharedScriptLibrary = new SharedScriptLibrary( compilationService, NullLogger.Instance); return new ScriptRuntimeContext( ActorRefs.Nobody, ActorRefs.Nobody, sharedScriptLibrary, currentCallDepth: maxCallDepth, // already AT the limit maxCallDepth: maxCallDepth, askTimeout: TimeSpan.FromSeconds(5), instanceName: InstanceName, logger: NullLogger.Instance, siteEventLogger: siteEventLogger); } // ------------------------------------------------------------------------- // CallScript // ------------------------------------------------------------------------- [Fact] public async Task CallScript_RecursionLimitExceeded_EmitsScriptErrorSiteEvent() { var loggerMock = new Mock(); loggerMock .Setup(l => l.LogEventAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var context = CreateContextAtLimit(loggerMock.Object); // The call must throw — recursion limit hit await Assert.ThrowsAsync( () => context.CallScript("AnyScript")); // Give the fire-and-forget task a moment to complete await Task.Yield(); loggerMock.Verify(l => l.LogEventAsync( "script", "Error", InstanceName, It.IsAny(), // source (may be null when sourceScript not set) It.Is(m => m.Contains("CallScript") && m.Contains("depth")), null), Times.Once); } [Fact] public async Task CallScript_RecursionLimitExceeded_NullLogger_DoesNotThrow() { // Null siteEventLogger — existing behaviour must be fully unchanged: // LogError + throw, no NullReferenceException from the emission path. var context = CreateContextAtLimit(siteEventLogger: null!); var ex = await Assert.ThrowsAsync( () => context.CallScript("AnyScript")); Assert.Contains("depth", ex.Message); } // ------------------------------------------------------------------------- // CallShared (via Scripts.CallShared) // ------------------------------------------------------------------------- [Fact] public async Task CallShared_RecursionLimitExceeded_EmitsScriptErrorSiteEvent() { var loggerMock = new Mock(); loggerMock .Setup(l => l.LogEventAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var context = CreateContextAtLimit(loggerMock.Object); await Assert.ThrowsAsync( () => context.Scripts.CallShared("AnySharedScript")); await Task.Yield(); loggerMock.Verify(l => l.LogEventAsync( "script", "Error", InstanceName, It.IsAny(), It.Is(m => m.Contains("CallShared") && m.Contains("depth")), null), Times.Once); } [Fact] public async Task CallShared_RecursionLimitExceeded_NullLogger_DoesNotThrow() { var context = CreateContextAtLimit(siteEventLogger: null!); var ex = await Assert.ThrowsAsync( () => context.Scripts.CallShared("AnySharedScript")); Assert.Contains("depth", ex.Message); } // ------------------------------------------------------------------------- // Verify the event shape precisely (category, severity, message content) // ------------------------------------------------------------------------- [Fact] public async Task CallScript_RecursionLimitExceeded_EventHasCorrectCategoryAndSeverity() { string? capturedCategory = null; string? capturedSeverity = null; string? capturedInstance = null; string? capturedSource = null; string? capturedMessage = null; var loggerMock = new Mock(); loggerMock .Setup(l => l.LogEventAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback( (cat, sev, inst, src, msg, det) => { capturedCategory = cat; capturedSeverity = sev; capturedInstance = inst; capturedSource = src; capturedMessage = msg; }) .Returns(Task.CompletedTask); var context = CreateContextAtLimit(loggerMock.Object, maxCallDepth: 2); await Assert.ThrowsAsync( () => context.CallScript("DeepScript")); await Task.Yield(); Assert.Equal("script", capturedCategory); Assert.Equal("Error", capturedSeverity); Assert.Equal(InstanceName, capturedInstance); // Source fallback: no sourceScript wired, so the helper uses "InstanceScript:{instanceName}". Assert.Equal($"InstanceScript:{InstanceName}", capturedSource); Assert.NotNull(capturedMessage); Assert.Contains("CallScript", capturedMessage); Assert.Contains("2", capturedMessage); // maxCallDepth in the message } }