Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/RecursionLimitSiteEventTests.cs
T
Joseph Doherty e2b31a9fd2 fix(siteruntime): M2.12 review nits — observe logger fault + meaningful source fallback (#25)
Replace bare task-discard with ContinueWith(OnlyOnFaulted|ExecuteSynchronously) so a
faulted ISiteEventLogger is logged and swallowed rather than going to the unobserved-task
firehose. Replace the "ScriptRuntimeContext" class-name fallback with the meaningful
"InstanceScript:{instanceName}" identifier (matching the site-event-log source convention).
Update the method doc-comment to state the best-effort contract explicitly. Pin the new
fallback value in the shape-precision test.
2026-06-16 06:26:00 -04:00

181 lines
6.8 KiB
C#

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;
/// <summary>
/// M2.12 (#25): recursion-limit violations at <c>CallScript</c> and
/// <c>CallShared</c> must emit a <c>script</c> Error site event via
/// <see cref="ISiteEventLogger"/> in addition to the existing
/// <c>ILogger.LogError</c> + throw.
/// </summary>
public class RecursionLimitSiteEventTests
{
private const string InstanceName = "test-instance";
private const string SourceScript = "ScriptActor:OnTick";
/// <summary>
/// Build a <see cref="ScriptRuntimeContext"/> wired at the recursion
/// limit (currentCallDepth == maxCallDepth) so the very next
/// <c>CallScript</c> or <c>CallShared</c> call trips the guard.
/// </summary>
private static ScriptRuntimeContext CreateContextAtLimit(
ISiteEventLogger siteEventLogger,
int maxCallDepth = 3)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.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<ISiteEventLogger>();
loggerMock
.Setup(l => l.LogEventAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var context = CreateContextAtLimit(loggerMock.Object);
// The call must throw — recursion limit hit
await Assert.ThrowsAsync<InvalidOperationException>(
() => 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<string>(), // source (may be null when sourceScript not set)
It.Is<string>(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<InvalidOperationException>(
() => context.CallScript("AnyScript"));
Assert.Contains("depth", ex.Message);
}
// -------------------------------------------------------------------------
// CallShared (via Scripts.CallShared)
// -------------------------------------------------------------------------
[Fact]
public async Task CallShared_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
{
var loggerMock = new Mock<ISiteEventLogger>();
loggerMock
.Setup(l => l.LogEventAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var context = CreateContextAtLimit(loggerMock.Object);
await Assert.ThrowsAsync<InvalidOperationException>(
() => context.Scripts.CallShared("AnySharedScript"));
await Task.Yield();
loggerMock.Verify(l => l.LogEventAsync(
"script",
"Error",
InstanceName,
It.IsAny<string>(),
It.Is<string>(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<InvalidOperationException>(
() => 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<ISiteEventLogger>();
loggerMock
.Setup(l => l.LogEventAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string?>()))
.Callback<string, string, string?, string, string, string?>(
(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<InvalidOperationException>(
() => 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
}
}