Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRouteTraceTests.cs
T
Joseph Doherty 6d5bb6189f feat(flow-debugger): carry site-side trace truncation on the route reply (ScadaBridge#54)
ScriptCallResult and RouteToCallResponse gain trailing Truncated/LogsTruncated,
filled from the site FlowTraceCollector after Complete(); RouteToCallResponseDto
fields 8/9 carry them (sitecommand regen). FlowDebugRunner ORs them into
FlowDebugRunResult on the script entry, and on the method entry through a new
optional truncation sink on RouteHelper.WithTraceSink (carried through every
With* clone). Previously a site run whose collector dropped rows came back as a
silently short list. Older sites send false.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 12:37:57 -05:00

167 lines
8.3 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Flow Debugger design 2026-09-05 §5.2/§5.4: the Deployment Manager's inbound-API route
/// handlers forward the <c>Trace</c> flag and map the routed run's <c>ExecutionId</c> and
/// inline <c>ScriptTrace</c> events onto the <see cref="RouteToCallResponse"/>. Fixture copied
/// from <c>DeploymentManagerActorTests</c> (real Instance/Script actors end-to-end).
/// </summary>
public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary>
public DeploymentManagerRouteTraceTests()
{
_localDb = TestLocalDb.CreateTemp("dm-route-trace-test");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null) =>
ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, _compilationService, _sharedScriptLibrary, null, options ?? new SiteRuntimeOptions(),
NullLogger<DeploymentManagerActor>.Instance,
dclManager: null, healthCollector: null, serviceProvider: null, loggerFactory: null, configFetcher: null)));
private static string ConfigJson(string instance) => JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instance,
Attributes = [new ResolvedAttribute { CanonicalName = "Mode", Value = "Auto", DataType = "String" }],
Scripts =
[
new ResolvedScript { CanonicalName = "DoWork", Code = "await Instance.SetAttribute(\"Mode\", \"Manual\"); return 7;" },
// Gitea #54: more log lines than the inline collector's low-priority share (450).
new ResolvedScript { CanonicalName = "Chatty", Code = "for (var i = 0; i < 600; i++) Log.Info(\"line \" + i); return 1;" },
],
});
private async Task<IActorRef> DeployedManager(string instance, SiteRuntimeOptions? options = null)
{
var actor = CreateDeploymentManager(options);
await Task.Delay(500);
actor.Tell(new DeployInstanceCommand("dep", instance, "sha256:x", ConfigJson(instance), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000);
return actor;
}
/// <summary>Verifies that a traced route call returns the child execution id and the inline trace events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteCall_WithTrace_ReturnsChildExecutionId_AndInlineTraceEvents()
{
var actor = await DeployedManager("TracedPump");
var parent = Guid.NewGuid();
actor.Tell(new RouteToCallRequest("rc-1", "TracedPump", "DoWork", null, DateTimeOffset.UtcNow,
ParentExecutionId: parent, Trace: true));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.True(response.Success, response.ErrorMessage);
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
Assert.NotNull(response.ChildExecutionId);
Assert.NotNull(response.TraceEvents);
var rows = response.TraceEvents!.Select(e => e.AsRow()).ToList();
// The script wrote an attribute (AttributeWrite) and the run itself (ScriptRun) — both
// stamped with the routed run's id and parented to the inbound request.
Assert.Contains(rows, r => r.Kind == Commons.Types.Enums.AuditKind.AttributeWrite);
var run = Assert.Single(rows, r => r.Kind == Commons.Types.Enums.AuditKind.ScriptRun);
Assert.Equal(response.ChildExecutionId, run.ExecutionId);
Assert.Equal(parent, run.ParentExecutionId);
Assert.All(rows, r => Assert.Equal(response.ChildExecutionId, r.ExecutionId));
}
/// <summary>
/// Gitea #54: a traced routed run whose inline collector dropped log rows carries
/// <c>LogsTruncated</c> on the <see cref="RouteToCallResponse"/>, so central can label the short
/// site lane; a run that fit reports neither flag.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteCall_WithTrace_CarriesTheSiteCollectorsTruncationFlags()
{
var actor = await DeployedManager("ChattyPump", new SiteRuntimeOptions { MaxLogLinesPerRun = 1000 });
actor.Tell(new RouteToCallRequest("rc-t", "ChattyPump", "Chatty", null, DateTimeOffset.UtcNow, Trace: true));
var chatty = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.True(chatty.Success, chatty.ErrorMessage);
Assert.True(chatty.LogsTruncated);
Assert.False(chatty.Truncated);
actor.Tell(new RouteToCallRequest("rc-q", "ChattyPump", "DoWork", null, DateTimeOffset.UtcNow, Trace: true));
var quiet = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.True(quiet.Success, quiet.ErrorMessage);
Assert.False(quiet.LogsTruncated);
Assert.False(quiet.Truncated);
}
/// <summary>Verifies that an untraced route call returns the execution id but no trace events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteCall_WithoutTrace_ReturnsExecutionId_ButNoTraceEvents()
{
var actor = await DeployedManager("PlainPump");
actor.Tell(new RouteToCallRequest("rc-2", "PlainPump", "DoWork", null, DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.True(response.Success, response.ErrorMessage);
Assert.NotNull(response.ChildExecutionId);
Assert.Null(response.TraceEvents);
}
/// <summary>Verifies that routing a set-attributes request forwards the trace flag on the command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteSetAttributes_ForwardsTraceFlag_OnTheCommand()
{
var actor = await DeployedManager("SetPump");
var parent = Guid.NewGuid();
actor.Tell(new RouteToSetAttributesRequest("rs-1", "SetPump",
new Dictionary<string, string> { ["Mode"] = "Hand" }, DateTimeOffset.UtcNow,
ParentExecutionId: parent, Trace: true));
var response = ExpectMsg<RouteToSetAttributesResponse>(TimeSpan.FromSeconds(10));
Assert.True(response.Success, response.ErrorMessage);
// The write landed with the trace stamp: reading it back through a debug snapshot is
// heavier than needed — the InstanceActorTraceArmTests pin the publish; here we assert
// the routed write succeeded end-to-end with the additive flag set, i.e. the command
// shape the handler builds is accepted by the Instance Actor.
actor.Tell(new RouteToGetAttributesRequest("rg-1", "SetPump", ["Mode"], DateTimeOffset.UtcNow));
var read = ExpectMsg<RouteToGetAttributesResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("Hand", read.Values["Mode"]?.ToString());
}
}