docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
@@ -28,6 +28,7 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
instanceName: "CvdReactor01",
|
||||
logger: NullLogger<ScriptRuntimeContext>.Instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
private static readonly DateTimeOffset RaisedAt = new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
@@ -63,6 +64,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
request.CorrelationId, request.InstanceUniqueName, alarms, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current-alarms call asks the instance actor for its own instance.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_AsksTheInstanceActor_ForItsOwnInstance()
|
||||
{
|
||||
@@ -84,6 +87,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.Empty(await pending);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current-alarms call projects every native alarm field, including the acknowledgement time.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_ProjectsEveryNativeField_IncludingAckTime()
|
||||
{
|
||||
@@ -118,6 +123,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.False(alarm.IsConfiguredPlaceholder);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unacknowledged native alarm reports no acknowledgement time.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_UnackedNativeAlarm_ReportsNoAckTime()
|
||||
{
|
||||
@@ -138,6 +145,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.False(alarm.Shelved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a computed alarm reads its state from the derived condition.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_ComputedAlarm_ReadsFromTheDerivedCondition()
|
||||
{
|
||||
@@ -161,6 +170,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that placeholder rows are surfaced so that quiet bindings can be told apart.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_SurfacesPlaceholderRows_SoQuietBindingsAreDistinguishable()
|
||||
{
|
||||
@@ -187,6 +198,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current-alarms call projects the MES-reportable flag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_ProjectsIsMesReportable()
|
||||
{
|
||||
@@ -206,6 +219,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.False(Assert.Single(alarms, a => a.Name == "HighTemp").IsMesReportable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the MES-reportable call returns only the flagged rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MesReportableAsync_FiltersToFlaggedRows()
|
||||
{
|
||||
@@ -224,6 +239,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.True(alarm.IsMesReportable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current-alarms call is not scope-prefixed, so every scope sees the same list.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList()
|
||||
{
|
||||
|
||||
+3
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
public class AttributeSchedulerRegressionTests : TestKit
|
||||
{
|
||||
/// <summary>Verifies that saturated workers complete synchronous attribute access without tripping the watchdog.</summary>
|
||||
/// <param name="write">The write.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
public class AuditingDbTransactionTests
|
||||
{
|
||||
/// <summary>Verifies that sr11, transaction connection and command readback remain audited.</summary>
|
||||
/// <param name="asynchronous">Test case input.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -43,6 +46,7 @@ public class AuditingDbTransactionTests
|
||||
Assert.Equal(42L, check.ExecuteScalar());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sr11, command connection cannot expose provider.</summary>
|
||||
[Fact]
|
||||
public void SR11_CommandConnectionCannotExposeProvider()
|
||||
{
|
||||
@@ -52,6 +56,7 @@ public class AuditingDbTransactionTests
|
||||
using var command = connection.CreateCommand();
|
||||
Assert.Same(connection, command.Connection);
|
||||
}
|
||||
/// <summary>Verifies that sr4, connection cannot be retargeted or reveal credentials.</summary>
|
||||
[Fact]
|
||||
public void SR4_ConnectionCannotBeRetargetedOrRevealCredentials()
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// M3.3: compile-surface parity guard. The shared
|
||||
/// Compile-surface parity guard. The shared
|
||||
/// <see cref="ScriptCompileSurface"/> / <see cref="TriggerCompileSurface"/> are
|
||||
/// compile-only stubs the design-time deploy gate binds candidate scripts
|
||||
/// against; they must mirror the real SiteRuntime <see cref="ScriptGlobals"/> /
|
||||
@@ -25,6 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// </summary>
|
||||
public class CompileSurfaceParityTests
|
||||
{
|
||||
/// <summary>Verifies that the script compile surface exposes every member name of the runtime script globals.</summary>
|
||||
[Fact]
|
||||
public void ScriptCompileSurface_MemberNames_AreSupersetOf_ScriptGlobals()
|
||||
{
|
||||
@@ -33,6 +34,7 @@ public class CompileSurfaceParityTests
|
||||
globals: typeof(ScriptGlobals));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the trigger compile surface exposes every member name of the trigger expression globals.</summary>
|
||||
[Fact]
|
||||
public void TriggerCompileSurface_MemberNames_AreSupersetOf_TriggerExpressionGlobals()
|
||||
{
|
||||
@@ -63,6 +65,8 @@ public class CompileSurfaceParityTests
|
||||
/// many overloads for each (name, arity) as the runtime accessor declares.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="runtimeAccessor">The runtime script accessor type whose public methods are enumerated.</param>
|
||||
/// <param name="compileMirror">The compile-only mirror type that must cover those methods.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AccessorMirrorPairs))]
|
||||
public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities(
|
||||
@@ -95,6 +99,7 @@ public class CompileSurfaceParityTests
|
||||
/// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by
|
||||
/// <see cref="CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities"/>.
|
||||
/// </summary>
|
||||
/// <returns>The runtime accessor and compile mirror type pairs, one per theory case.</returns>
|
||||
public static IEnumerable<object[]> AccessorMirrorPairs() =>
|
||||
new[]
|
||||
{
|
||||
|
||||
+46
-20
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M3 Bundle E (Task E6): every script-initiated
|
||||
/// Every script-initiated
|
||||
/// <c>Database.CachedWrite</c> emits exactly one <c>CachedSubmit</c>
|
||||
/// combined-telemetry packet at enqueue time on the <c>DbOutbound</c>
|
||||
/// channel, returns a fresh <see cref="TrackedOperationId"/>, and threads
|
||||
@@ -22,9 +22,12 @@ public class DatabaseCachedWriteEmissionTests
|
||||
{
|
||||
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
|
||||
{
|
||||
/// <summary>Gets the telemetry packets captured so far.</summary>
|
||||
public List<CachedCallTelemetry> Telemetry { get; } = new();
|
||||
/// <summary>Gets or sets an exception to throw from <see cref="ForwardAsync"/> instead of capturing the packet.</summary>
|
||||
public Exception? ThrowOnForward { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnForward != null)
|
||||
@@ -41,7 +44,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
private const string SourceScript = "ScriptActor:WriteAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
|
||||
/// A fixed per-execution id so the cached-row tests can
|
||||
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
@@ -55,7 +58,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
gateway,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
// Audit Log #23: the per-execution id stamped into ExecutionId on
|
||||
// The per-execution id stamped into ExecutionId on
|
||||
// every script-side row. Cached rows keep CorrelationId =
|
||||
// TrackedOperationId (the per-operation lifecycle id).
|
||||
TestExecutionId,
|
||||
@@ -65,6 +68,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
parentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that enqueueing a cached write emits a CachedSubmit telemetry packet on the DbOutbound channel.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound()
|
||||
{
|
||||
@@ -77,7 +82,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -99,7 +104,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
// ExecutionId is the per-execution id from the runtime context.
|
||||
Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId);
|
||||
Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId);
|
||||
// Audit Log #23 (ParentExecutionId): null for a non-routed run.
|
||||
// ParentExecutionId is null for a non-routed run.
|
||||
Assert.Null(packet.Audit.AsRow().ParentExecutionId);
|
||||
|
||||
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
|
||||
@@ -111,6 +116,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the emitted telemetry carries populated provenance fields.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ProvenancePopulated()
|
||||
{
|
||||
@@ -123,7 +130,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -141,10 +148,12 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.Equal(SiteId, packet.Operational.SourceSite);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed run stamps its parent execution id on the submit telemetry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_RoutedRun_StampsParentExecutionId_OnSubmitTelemetry()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
|
||||
// An inbound-API-routed run
|
||||
// carries the spawning execution's id; the CachedSubmit telemetry row
|
||||
// must stamp it in ParentExecutionId.
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
@@ -157,7 +166,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -172,6 +181,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached write returns a tracked operation id and passes the same id to the gateway.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway()
|
||||
{
|
||||
@@ -184,7 +195,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -207,7 +218,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
|
||||
/// The helper → gateway hop of the
|
||||
/// threading chain. The cached-write helper must forward the runtime
|
||||
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
|
||||
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> — so the buffered retry
|
||||
@@ -215,6 +226,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
/// asserts the exact id/script (not <c>It.IsAny</c>), so a regression that
|
||||
/// dropped the threading would fail here.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway()
|
||||
{
|
||||
@@ -227,7 +239,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -253,13 +265,14 @@ public class DatabaseCachedWriteEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
|
||||
/// The helper → gateway hop for
|
||||
/// <c>ParentExecutionId</c>. A cached write enqueued from an inbound-API-
|
||||
/// routed script run must forward the runtime context's
|
||||
/// <c>ParentExecutionId</c> verbatim into
|
||||
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> so the buffered retry
|
||||
/// loop later stamps it onto its audit rows.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway()
|
||||
{
|
||||
@@ -273,7 +286,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -296,9 +309,10 @@ public class DatabaseCachedWriteEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
|
||||
/// A non-routed run threads a
|
||||
/// <c>null</c> ParentExecutionId into the gateway — the additive default.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway()
|
||||
{
|
||||
@@ -311,7 +325,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -333,6 +347,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached write still returns a tracked operation id when the telemetry forwarder throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId()
|
||||
{
|
||||
@@ -345,7 +361,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -370,8 +386,10 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── SourceNode-stamping (Task 14) ──
|
||||
// ── SourceNode-stamping ──
|
||||
|
||||
/// <summary>Verifies that the submit telemetry row is stamped with the source node.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_StampsSourceNode_OnSubmitTelemetryRow()
|
||||
{
|
||||
@@ -388,7 +406,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -414,6 +432,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.Equal("node-a", packet.Operational.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the source node stays null when no source node is wired.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull()
|
||||
{
|
||||
@@ -429,7 +449,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
||||
// M2.3 (#7): CachedWriteAsync now returns an ExternalCallResult. The
|
||||
// CachedWriteAsync returns an ExternalCallResult. The
|
||||
// buffered result (WasBuffered=true) models the transient-failure
|
||||
// path these enqueue-telemetry tests exercise — only the CachedSubmit
|
||||
// packet is emitted; the S&F retry loop (not the helper) owns the
|
||||
@@ -444,7 +464,7 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.Null(packet.Operational.SourceNode);
|
||||
}
|
||||
|
||||
// ── M2.3 (#7): immediate-completion lifecycle (WasBuffered=false) ──
|
||||
// ── Immediate-completion lifecycle (WasBuffered=false) ──
|
||||
|
||||
private static Mock<IDatabaseGateway> GatewayReturning(ExternalCallResult result)
|
||||
{
|
||||
@@ -461,6 +481,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
return gateway;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an immediate success emits submit and attempted telemetry followed by a delivered resolve.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ImmediateSuccess_EmitsSubmitAttemptedThenDeliveredResolve()
|
||||
{
|
||||
@@ -494,6 +516,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an immediate permanent failure emits submit and attempted telemetry followed by a failed resolve.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ImmediatePermanentFailure_EmitsSubmitAttemptedThenFailedResolve()
|
||||
{
|
||||
@@ -520,6 +544,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
Assert.NotEqual(default, trackedId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered transient failure emits only the submit telemetry and no terminal rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedWrite_BufferedTransient_EmitsOnlySubmit_NoTerminalRows()
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A (Tasks A1+A2): every synchronous DB call made
|
||||
/// Every synchronous DB call made
|
||||
/// through <c>Database.Connection("name")</c> emits exactly one
|
||||
/// <c>DbOutbound</c>/<c>DbWrite</c> audit event with an <c>Extra</c> envelope
|
||||
/// distinguishing writes (<c>op="write"</c>, <c>rowsAffected=N</c>) from reads
|
||||
@@ -23,18 +23,21 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
public class DatabaseSyncEmissionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IAuditWriter"/> mirroring the M2 Bundle F stub —
|
||||
/// In-memory <see cref="IAuditWriter"/> mirroring the shared audit-writer stub —
|
||||
/// captures every event and may be configured to throw to verify the
|
||||
/// 3-layer fail-safe (mirrors <c>CapturingAuditWriter</c> in
|
||||
/// <c>ExternalSystemCallAuditEmissionTests</c>).
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>Gets the captured audit rows.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets whether the writer throws on every write.</summary>
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnWrite != null)
|
||||
@@ -53,7 +56,7 @@ public class DatabaseSyncEmissionTests
|
||||
private const string ConnectionName = "machineData";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id used by the default
|
||||
/// A fixed per-execution id used by the default
|
||||
/// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
|
||||
/// overload so assertions can compare against a known value.
|
||||
/// </summary>
|
||||
@@ -116,6 +119,8 @@ public class DatabaseSyncEmissionTests
|
||||
return live;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful insert emits one DbWrite event with Delivered status, a write op and the affected row count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Execute_InsertSuccess_EmitsOneEvent_KindDbWrite_StatusDelivered_OpWrite_RowsAffected()
|
||||
{
|
||||
@@ -147,6 +152,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.StartsWith(ConnectionName, evt.Target);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful scalar execution emits a DbWrite event with a write op.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteScalar_Success_EmitsKindDbWrite_OpWrite()
|
||||
{
|
||||
@@ -170,7 +177,7 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.NotNull(evt.Extra);
|
||||
// ExecuteScalar is classified as "write" per the M4 vocabulary lock
|
||||
// ExecuteScalar is classified as "write" per the audit vocabulary lock
|
||||
// (Channel=DbOutbound, Kind=DbWrite, Extra.op="write") — the
|
||||
// rowsAffected for a SELECT-on-SqlCommand is -1 in ADO.NET; the audit
|
||||
// wrapper records whatever DbCommand.ExecuteScalar returned via the
|
||||
@@ -179,6 +186,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Contains("rowsAffected", evt.Extra);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failing execution emits an event with Failed status and the error message set.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Execute_Throws_EmitsEvent_StatusFailed_ErrorMessageSet()
|
||||
{
|
||||
@@ -204,6 +213,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Contains("does_not_exist", evt.ErrorDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful reader execution emits a DbWrite event with a read op and the rows returned.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteReader_Success_EmitsKindDbWrite_OpRead_RowsReturned()
|
||||
{
|
||||
@@ -239,6 +250,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Contains("\"rowsReturned\":2", evt.Extra);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer does not change the script call's result.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriter_Throws_ScriptCall_ReturnsOriginalResult()
|
||||
{
|
||||
@@ -265,6 +278,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Empty(writer.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the event provenance fields are populated from the execution context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Provenance_PopulatedFromContext()
|
||||
{
|
||||
@@ -288,21 +303,23 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
// Audit Log #23: the sync DbWrite row carries the per-execution id the
|
||||
// The sync DbWrite row carries the per-execution id the
|
||||
// helper was constructed with in ExecutionId. CorrelationId is null —
|
||||
// a sync one-shot call has no operation lifecycle.
|
||||
Assert.Equal(TestExecutionId, evt.ExecutionId);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
// Audit Log #23 (ParentExecutionId): null for a non-routed run — the
|
||||
// ParentExecutionId: null for a non-routed run — the
|
||||
// default CreateHelper supplies no parentExecutionId.
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a sync DbWrite in a routed run stamps the parent execution id from the context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
|
||||
// ParentExecutionId: an inbound-API-routed run
|
||||
// carries the spawning execution's id; the sync DbWrite row must stamp
|
||||
// it in ParentExecutionId alongside its own fresh ExecutionId.
|
||||
using var keepAlive = new SqliteConnection("Data Source=kp;Mode=Memory;Cache=Shared");
|
||||
@@ -326,6 +343,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Equal(executionId, evt.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a sync DbWrite in a non-routed run leaves the parent execution id null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull()
|
||||
{
|
||||
@@ -349,6 +368,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a sync DbWrite stamps the execution id and leaves the correlation id null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId()
|
||||
{
|
||||
@@ -373,6 +394,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the emitted event records a non-zero duration.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DurationMs_NonZero()
|
||||
{
|
||||
|
||||
+15
-3
@@ -12,7 +12,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — execution-correlation tests exercised through a full
|
||||
/// Audit Log execution-correlation tests exercised through a full
|
||||
/// <see cref="ScriptRuntimeContext"/>:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
@@ -40,10 +40,12 @@ public class ExecutionCorrelationContextTests
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>The audit rows captured by this writer, in write order.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Events.Add(evt.AsRow());
|
||||
@@ -119,6 +121,8 @@ public class ExecutionCorrelationContextTests
|
||||
return live;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no execution id supplied, sync call, stamps fresh non empty execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId()
|
||||
{
|
||||
@@ -141,6 +145,8 @@ public class ExecutionCorrelationContextTests
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that same context, api call and db write, share the same execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId()
|
||||
{
|
||||
@@ -189,10 +195,12 @@ public class ExecutionCorrelationContextTests
|
||||
Assert.Null(dbRow.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parent execution id supplied, stamped on emitted row, and distinct from own execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed call
|
||||
// ParentExecutionId: an inbound-API-routed call
|
||||
// supplies the spawning execution's ExecutionId as the routed script's
|
||||
// ParentExecutionId. Every audit row the routed script emits must carry
|
||||
// that value in AuditEvent.ParentExecutionId — and still carry its OWN
|
||||
@@ -224,6 +232,8 @@ public class ExecutionCorrelationContextTests
|
||||
Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no parent execution id supplied, non routed run, parent stays null on emitted row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow()
|
||||
{
|
||||
@@ -246,6 +256,8 @@ public class ExecutionCorrelationContextTests
|
||||
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parent execution id supplied, stamped on api and db rows, from same context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext()
|
||||
{
|
||||
|
||||
+42
-15
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M3 Bundle E (Task E3): every script-initiated
|
||||
/// Every script-initiated
|
||||
/// <c>ExternalSystem.CachedCall</c> emits exactly one <c>CachedSubmit</c>
|
||||
/// combined-telemetry packet at enqueue time, returns a fresh
|
||||
/// <see cref="TrackedOperationId"/>, and threads that id down to the
|
||||
@@ -24,9 +24,12 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
{
|
||||
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
|
||||
{
|
||||
/// <summary>Gets the telemetry packets the forwarder has received.</summary>
|
||||
public List<CachedCallTelemetry> Telemetry { get; } = new();
|
||||
/// <summary>Gets or sets a value indicating whether the forwarder should throw when forwarding.</summary>
|
||||
public Exception? ThrowOnForward { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnForward != null)
|
||||
@@ -43,7 +46,7 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
private const string SourceScript = "ScriptActor:CheckPressure";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
|
||||
/// A fixed per-execution id so the cached-row tests can
|
||||
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
@@ -57,7 +60,7 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
client,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
// Audit Log #23: the per-execution id stamped into ExecutionId on
|
||||
// The per-execution id stamped into ExecutionId on
|
||||
// every script-side row. Cached rows keep CorrelationId =
|
||||
// TrackedOperationId (the per-operation lifecycle id).
|
||||
TestExecutionId,
|
||||
@@ -68,6 +71,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
parentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached call emits submit telemetry on enqueue.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_EmitsSubmitTelemetry_OnEnqueue()
|
||||
{
|
||||
@@ -98,7 +103,7 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
// ExecutionId is the per-execution id from the runtime context.
|
||||
Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId);
|
||||
Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId);
|
||||
// C3 (Task 2.5): ForwardState is no longer a field on the canonical
|
||||
// ForwardState is no longer a field on the canonical
|
||||
// record — it is a site-storage-only concern (the SQLite writer defaults
|
||||
// it to Pending on insert), so the telemetry packet no longer carries it.
|
||||
|
||||
@@ -113,6 +118,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an immediately completed cached call captures the request arguments and the response body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
|
||||
{
|
||||
@@ -150,6 +157,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Assert.Equal("{\"ok\":true}", resolve.Audit.AsRow().ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached call returns a tracked operation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ReturnsTrackedOperationId()
|
||||
{
|
||||
@@ -194,7 +203,7 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
|
||||
/// The helper → gateway hop of the
|
||||
/// threading chain. The cached-call helper must forward the runtime
|
||||
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
|
||||
/// <see cref="IExternalSystemClient.CachedCallAsync"/> — so the buffered
|
||||
@@ -202,6 +211,7 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
/// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression
|
||||
/// that dropped the threading would fail here.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient()
|
||||
{
|
||||
@@ -235,13 +245,14 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for
|
||||
/// The helper → gateway hop for
|
||||
/// <c>ParentExecutionId</c>. A cached call enqueued from an inbound-API-
|
||||
/// routed script run must forward the runtime context's
|
||||
/// <c>ParentExecutionId</c> verbatim into
|
||||
/// <see cref="IExternalSystemClient.CachedCallAsync"/> so the buffered
|
||||
/// retry loop later stamps it onto its audit rows.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ThreadsParentExecutionId_IntoClient()
|
||||
{
|
||||
@@ -273,9 +284,10 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
|
||||
/// A non-routed run threads a
|
||||
/// <c>null</c> ParentExecutionId into the client — the additive default.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient()
|
||||
{
|
||||
@@ -305,6 +317,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached call still returns the tracked operation id and proceeds when the forwarder throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds()
|
||||
{
|
||||
@@ -340,6 +354,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cached call provenance is populated from the context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_Provenance_Populated_FromContext()
|
||||
{
|
||||
@@ -365,6 +381,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Assert.Equal(SiteId, packet.Operational.SourceSite);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached call without a forwarder still returns the tracked operation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_NoForwarder_StillReturnsTrackedOperationId()
|
||||
{
|
||||
@@ -396,20 +414,21 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M3 Bundle F (F2): when the underlying client call
|
||||
/// When the underlying client call
|
||||
/// completes immediately (no S&F buffering, <c>WasBuffered=false</c>),
|
||||
/// the S&F retry loop never engages and the
|
||||
/// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call
|
||||
/// helper itself must therefore emit the terminal lifecycle rows —
|
||||
/// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c>
|
||||
/// forever and the audit log would be missing the <c>Attempted</c> /
|
||||
/// <c>CachedResolve</c> pair the M3 contract requires.
|
||||
/// <c>CachedResolve</c> pair the contract requires.
|
||||
///
|
||||
/// Expected emissions on immediate success:
|
||||
/// 1. CachedSubmit / Submitted (already covered)
|
||||
/// 2. ApiCallCached / Attempted
|
||||
/// 3. CachedResolve / Delivered (TerminalAtUtc set)
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ImmediateSuccess_EmitsAttemptedAndCachedResolve()
|
||||
{
|
||||
@@ -464,17 +483,19 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
// Terminal row carries TerminalAtUtc.
|
||||
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
||||
|
||||
// Audit Log #23 (ParentExecutionId): null on every script-side cached
|
||||
// ParentExecutionId: null on every script-side cached
|
||||
// row for a non-routed run.
|
||||
Assert.Null(submit.Audit.AsRow().ParentExecutionId);
|
||||
Assert.Null(attempted.Audit.AsRow().ParentExecutionId);
|
||||
Assert.Null(resolve.Audit.AsRow().ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed run stamps the parent execution id on all script-side rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
|
||||
// An inbound-API-routed run
|
||||
// carries the spawning execution's id; every script-side cached row
|
||||
// (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in
|
||||
// ParentExecutionId.
|
||||
@@ -500,12 +521,13 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M3 Bundle F (F2): the immediate-failure terminal
|
||||
/// The immediate-failure terminal
|
||||
/// path. When the client returns <c>Success=false</c> with
|
||||
/// <c>WasBuffered=false</c> (a permanent failure or a transient failure
|
||||
/// without an S&F engine to buffer it), the cached-call helper must
|
||||
/// still emit Attempted + CachedResolve with the failed status.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_ImmediateFailure_EmitsAttemptedAndCachedResolveFailed()
|
||||
{
|
||||
@@ -543,12 +565,13 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M3 Bundle F (F2): when the client reports
|
||||
/// When the client reports
|
||||
/// <c>WasBuffered=true</c>, the helper hands the operation to S&F and
|
||||
/// the retry-loop observer owns the Attempted + Resolve emissions. The
|
||||
/// helper must NOT emit those rows itself (otherwise we'd get duplicate
|
||||
/// Attempted + Resolve audit rows under the same tracking id).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_BufferedPath_DoesNotEmitTerminalTelemetryFromHelper()
|
||||
{
|
||||
@@ -574,12 +597,14 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Assert.Equal(AuditKind.CachedSubmit, only.Audit.AsRow().Kind);
|
||||
}
|
||||
|
||||
// ── SourceNode-stamping (Task 14) ──
|
||||
// ── SourceNode-stamping ──
|
||||
|
||||
/// <summary>Verifies that a cached call stamps the source node on every site call operational row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow()
|
||||
{
|
||||
// SourceNode-stamping (Task 14): when the helper is constructed with
|
||||
// SourceNode stamping: when the helper is constructed with
|
||||
// a non-null sourceNode, every SiteCallOperational it produces
|
||||
// (CachedSubmit on enqueue + the immediate-completion Attempted/
|
||||
// CachedResolve pair when WasBuffered=false) carries that node name.
|
||||
@@ -614,6 +639,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached call with no source node wired leaves the source node null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull()
|
||||
{
|
||||
|
||||
+34
-7
@@ -11,7 +11,7 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M2 Bundle F (Task F1): every script-initiated
|
||||
/// Every script-initiated
|
||||
/// <c>ExternalSystem.Call</c> emits exactly one <c>ApiOutbound</c>/<c>ApiCall</c>
|
||||
/// audit event via the wrapper inside
|
||||
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>. The audit emission
|
||||
@@ -28,11 +28,14 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>Gets the captured audit rows.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets an exception to throw from WriteAsync.</summary>
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnWrite != null)
|
||||
@@ -50,7 +53,7 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
private const string SourceScript = "ScriptActor:CheckPressure";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id used by the default
|
||||
/// A fixed per-execution id used by the default
|
||||
/// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
|
||||
/// overload so assertions can compare against a known value.
|
||||
/// </summary>
|
||||
@@ -79,6 +82,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
parentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful call emits one ApiOutbound ApiCall event with a Delivered status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered()
|
||||
{
|
||||
@@ -98,7 +103,7 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(AuditKind.ApiCall, evt.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.Equal("ERP.GetOrder", evt.Target);
|
||||
// C3 (Task 2.5): ForwardState is no longer a field on the canonical
|
||||
// ForwardState is no longer a field on the canonical
|
||||
// record — it is a site-storage-only concern. The emitter no longer
|
||||
// sets it; the site SQLite writer defaults it to Pending on insert.
|
||||
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
||||
@@ -109,6 +114,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal("{}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit row captures the request arguments and the response body.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
|
||||
{
|
||||
@@ -129,6 +136,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 500 emits a Failed event with status 500 and an error message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set()
|
||||
{
|
||||
@@ -150,6 +159,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Contains("500", evt.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP 400 emits a Failed event with status 400.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400()
|
||||
{
|
||||
@@ -169,6 +180,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(400, evt.HttpStatus);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a client network exception emits a Failed event whose error message comes from the exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException()
|
||||
{
|
||||
@@ -192,6 +205,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Contains("HttpRequestException", evt.ErrorDetail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer leaves the script call result unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged()
|
||||
{
|
||||
@@ -212,6 +227,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Empty(writer.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that provenance fields are populated from the context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Provenance_Populated_FromContext()
|
||||
{
|
||||
@@ -234,20 +251,22 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
// Audit Log #23: the sync ApiCall row carries the per-execution id the
|
||||
// The sync ApiCall row carries the per-execution id the
|
||||
// helper was constructed with in ExecutionId. CorrelationId is null —
|
||||
// a sync one-shot call has no operation lifecycle.
|
||||
Assert.Equal(TestExecutionId, evt.ExecutionId);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
// Audit Log #23 (ParentExecutionId): null for a non-routed run — the
|
||||
// ParentExecutionId: null for a non-routed run — the
|
||||
// default CreateHelper supplies no parentExecutionId.
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed run stamps the parent execution id from the context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_RoutedRun_StampsParentExecutionId_FromContext()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
|
||||
// ParentExecutionId: an inbound-API-routed run
|
||||
// carries the spawning execution's id; the sync ApiCall row must stamp
|
||||
// it in ParentExecutionId alongside its own fresh ExecutionId.
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
@@ -265,6 +284,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(TestExecutionId, evt.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-routed run has a null parent execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_NonRoutedRun_ParentExecutionIdIsNull()
|
||||
{
|
||||
@@ -283,6 +304,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a sync API call stamps the execution id and a null correlation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
|
||||
{
|
||||
@@ -302,6 +325,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that two calls on the same helper share the same execution id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
|
||||
{
|
||||
@@ -326,6 +351,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Null(writer.Events[1].CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the duration is recorded as non-zero.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DurationMs_Recorded_NonZero()
|
||||
{
|
||||
|
||||
@@ -11,13 +11,13 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Notification Outbox (Task 19): tests for the async <c>Notify.Send</c> /
|
||||
/// Notification Outbox: tests for the async <c>Notify.Send</c> /
|
||||
/// <c>Notify.Status</c> script API.
|
||||
///
|
||||
/// In the outbox design <c>Notify.To("list").Send(...)</c> no longer delivers email
|
||||
/// inline — it generates a stable <c>NotificationId</c>, enqueues a
|
||||
/// <see cref="StoreAndForwardCategory.Notification"/> message into the site
|
||||
/// Store-and-Forward Engine (which Task 18 retargets to forward to central), and
|
||||
/// Store-and-Forward Engine (which forwards to central), and
|
||||
/// returns the <c>NotificationId</c> immediately. <c>Notify.Status(id)</c> queries
|
||||
/// central for delivery status, reporting the site-local <c>Forwarding</c> state
|
||||
/// while the notification is still buffered at the site.
|
||||
@@ -28,6 +28,7 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
private readonly StoreAndForwardStorage _storage;
|
||||
private readonly StoreAndForwardService _saf;
|
||||
|
||||
/// <summary>Initializes the test with an in-memory store-and-forward storage and service.</summary>
|
||||
public NotifyHelperTests()
|
||||
{
|
||||
// LocalDb has no in-memory mode, so the store runs over a real temp file.
|
||||
@@ -43,10 +44,15 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Initializes the storage before the tests run.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
||||
|
||||
/// <summary>Does nothing; storage is released in <c>Dispose</c>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
@@ -79,6 +85,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
sourceNode: sourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, enqueues notification into store and forward, and returns notification id immediately.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_EnqueuesNotificationIntoStoreAndForward_AndReturnsNotificationIdImmediately()
|
||||
{
|
||||
@@ -95,6 +103,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(1, depth.GetValueOrDefault(StoreAndForwardCategory.Notification));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, buffered payload, carries list subject body and notification id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_BufferedPayload_CarriesListSubjectBodyAndNotificationId()
|
||||
{
|
||||
@@ -123,6 +133,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Plant.Pump3", payload.SourceInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, when helper has source script, stamps it on the notification submit.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_WhenHelperHasSourceScript_StampsItOnTheNotificationSubmit()
|
||||
{
|
||||
@@ -140,10 +152,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, stamps execution id, on the notification submit payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload()
|
||||
{
|
||||
// Audit Log #23 (ExecutionId Task 5): Notify.Send must stamp the
|
||||
// ExecutionId: Notify.Send must stamp the
|
||||
// script run's ExecutionId onto the NotificationSubmit so it rides
|
||||
// inside the serialized S&F payload to central, where the dispatcher
|
||||
// echoes it onto the NotifyDeliver rows. This is the SAME id stamped
|
||||
@@ -161,10 +175,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(executionId, payload!.OriginExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, stamps parent execution id, on the notification submit payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_StampsParentExecutionId_OnTheNotificationSubmitPayload()
|
||||
{
|
||||
// Audit Log ParentExecutionId (Task 7): for an inbound-API-routed run,
|
||||
// ParentExecutionId: for an inbound-API-routed run,
|
||||
// Notify.Send must stamp the routed run's parent ExecutionId onto the
|
||||
// NotificationSubmit so it rides inside the serialized S&F payload to
|
||||
// central, where the dispatcher echoes it onto the NotifyDeliver rows.
|
||||
@@ -182,6 +198,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(parentExecutionId, payload!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, non routed run, leaves origin parent execution id null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_NonRoutedRun_LeavesOriginParentExecutionIdNull()
|
||||
{
|
||||
@@ -199,10 +217,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(payload!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, stamps source node, on the notification submit payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_StampsSourceNode_OnTheNotificationSubmitPayload()
|
||||
{
|
||||
// SourceNode-stamping (Task 13): when the helper is wired with the
|
||||
// SourceNode-stamping: when the helper is wired with the
|
||||
// local INodeIdentityProvider's NodeName, Notify.Send must stamp it
|
||||
// onto the NotificationSubmit so it rides inside the serialized S&F
|
||||
// payload to central, where NotificationOutboxActor persists it on
|
||||
@@ -219,6 +239,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal("node-a", payload!.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, no node identity, leaves source node null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_NoNodeIdentity_LeavesSourceNodeNull()
|
||||
{
|
||||
@@ -238,6 +260,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(payload!.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that send, when helper has no source script, leaves source script null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull()
|
||||
{
|
||||
@@ -252,6 +276,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(payload!.SourceScript);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status, when still buffered at site, reports forwarding.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Status_WhenStillBufferedAtSite_ReportsForwarding()
|
||||
{
|
||||
@@ -277,6 +303,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Forwarding", status.Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status, when central reports delivered, maps the central response.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Status_WhenCentralReportsDelivered_MapsTheCentralResponse()
|
||||
{
|
||||
@@ -299,6 +327,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(deliveredAt, status.DeliveredAt);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status, when central not found and not buffered, reports unknown.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Status_WhenCentralNotFoundAndNotBuffered_ReportsUnknown()
|
||||
{
|
||||
@@ -318,10 +348,12 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal("Unknown", status.Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that notify send, buffers unbounded, and never invokes forwarder inline.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NotifySend_BuffersUnbounded_AndNeverInvokesForwarderInline()
|
||||
{
|
||||
// Task 13: Notify.Send must buffer with maxRetries: 0 (never park for retry
|
||||
// Notify.Send must buffer with maxRetries: 0 (never park for retry
|
||||
// exhaustion) and deferToSweep: true (never run the forwarder's central Ask
|
||||
// inline on the scarce script thread). A central outage must return in
|
||||
// milliseconds, and the row must not strand behind operator unparking.
|
||||
|
||||
+34
-7
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle C (Task C1): every script-initiated
|
||||
/// Every script-initiated
|
||||
/// <c>Notify.To("list").Send(...)</c> emits exactly one
|
||||
/// <c>Notification</c>/<c>NotifySend</c> audit event via the wrapper inside
|
||||
/// <see cref="ScriptRuntimeContext.NotifyTarget"/>. The audit emission is
|
||||
@@ -33,11 +33,14 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>Gets the recorded audit rows.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets an exception to throw on write.</summary>
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnWrite != null)
|
||||
@@ -58,7 +61,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
private const string Body = "Pump 3 tripped";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id so the NotifySend test can
|
||||
/// A fixed per-execution id so the NotifySend test can
|
||||
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
@@ -67,6 +70,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
private readonly StoreAndForwardStorage _storage;
|
||||
private readonly StoreAndForwardService _saf;
|
||||
|
||||
/// <summary>Initializes the test class with in-memory storage and a recording audit writer.</summary>
|
||||
public NotifySendAuditEmissionTests()
|
||||
{
|
||||
// LocalDb has no in-memory mode, so the store runs over a real temp file.
|
||||
@@ -82,10 +86,15 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Prepares the test fixture before the first test runs.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
||||
|
||||
/// <summary>Does nothing; cleanup happens in <c>Dispose</c>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
@@ -118,6 +127,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
parentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful send emits one NotifySend event with Submitted status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted()
|
||||
{
|
||||
@@ -144,6 +155,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the audit target is populated with the list name.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_PopulatesTarget_AsListName()
|
||||
{
|
||||
@@ -156,6 +169,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(ListName, evt.Target);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the request summary is populated as the subject and body JSON.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson()
|
||||
{
|
||||
@@ -168,7 +183,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.NotNull(evt.RequestSummary);
|
||||
// Round-trip the JSON to assert the exact shape, not raw text — the
|
||||
// contract is "JSON of {subject, body}", which downstream redaction
|
||||
// (M5) can reshape; M4 captures verbatim.
|
||||
// can reshape; the row captures it verbatim.
|
||||
using var doc = JsonDocument.Parse(evt.RequestSummary!);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(JsonValueKind.Object, root.ValueKind);
|
||||
@@ -176,6 +191,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(Body, root.GetProperty("body").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer does not prevent the original send from returning.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_AuditWriter_Throws_OriginalSendStillReturns()
|
||||
{
|
||||
@@ -201,6 +218,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Empty(writer.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the provenance fields are populated from the script context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_Provenance_PopulatedFromContext()
|
||||
{
|
||||
@@ -217,6 +236,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the notification id is parsed as the correlation id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_NotificationIdParsed_AsCorrelationId()
|
||||
{
|
||||
@@ -235,14 +256,16 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.NotNull(evt.CorrelationId);
|
||||
Assert.Equal(expected, evt.CorrelationId);
|
||||
Assert.Equal(TestExecutionId, evt.ExecutionId);
|
||||
// Audit Log #23 (ParentExecutionId): null for a non-routed run.
|
||||
// ParentExecutionId: null for a non-routed run.
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed run stamps the parent execution id on the NotifySend row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow()
|
||||
{
|
||||
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
|
||||
// ParentExecutionId: an inbound-API-routed run
|
||||
// carries the spawning execution's id; the NotifySend row must stamp
|
||||
// it in ParentExecutionId alongside its own ExecutionId.
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
@@ -256,6 +279,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(TestExecutionId, evt.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-routed run leaves the parent execution id null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_NonRoutedRun_ParentExecutionIdIsNull()
|
||||
{
|
||||
@@ -270,10 +295,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sending without an audit writer still returns a notification id and enqueues the message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues()
|
||||
{
|
||||
// Audit is opt-in (mirrors M2 Bundle F behaviour): a null writer must
|
||||
// Audit is opt-in: a null writer must
|
||||
// degrade to a no-op audit path so tests / minimal hosts that don't
|
||||
// wire AddAuditLog still work.
|
||||
var notify = CreateHelper(auditWriter: null);
|
||||
|
||||
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested
|
||||
/// ParentExecutionId tag-cascade: nested
|
||||
/// <c>CallScript</c> / <c>CallShared</c> invocations and alarm on-trigger runs
|
||||
/// must form a true execution tree, where each spawned run records its
|
||||
/// immediate spawner's <c>ExecutionId</c> as its <c>ParentExecutionId</c>.
|
||||
@@ -52,8 +52,10 @@ public class ParentExecutionTreeTests : TestKit
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
/// <summary>Gets the audit rows captured so far.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Events.Add(evt.AsRow());
|
||||
@@ -101,6 +103,8 @@ public class ParentExecutionTreeTests : TestKit
|
||||
// Nested CallScript (actor-routed) — A → CallScript(B)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that <c>CallScript</c> stamps the calling run's own execution id as the child's parent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_StampsCallingRunsOwnExecutionId_AsChildParent()
|
||||
{
|
||||
@@ -122,6 +126,8 @@ public class ParentExecutionTreeTests : TestKit
|
||||
await call;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>CallScript</c> from a routed run uses the run's own execution id rather than an inherited parent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_FromRoutedRun_UsesOwnExecutionId_NotInheritedParent()
|
||||
{
|
||||
@@ -151,6 +157,8 @@ public class ParentExecutionTreeTests : TestKit
|
||||
// Nested CallShared (inline) — B → CallShared(C)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a <c>CallShared</c> child run has the caller's execution id as parent and a fresh execution id of its own.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallShared_ChildRun_ParentIsCallersExecutionId_FreshOwnExecutionId()
|
||||
{
|
||||
@@ -186,6 +194,8 @@ public class ParentExecutionTreeTests : TestKit
|
||||
Assert.NotEqual(bExecutionId, evt.ExecutionId!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a <c>CallShared</c> from a routed run has the caller as parent rather than an inherited grandparent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallShared_FromRoutedRun_ChildParentIsCaller_NotInheritedGrandparent()
|
||||
{
|
||||
@@ -222,6 +232,7 @@ public class ParentExecutionTreeTests : TestKit
|
||||
// Alarm on-trigger plumbing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that creating a child context for a shared script makes the caller's execution the parent and mints a fresh id.</summary>
|
||||
[Fact]
|
||||
public void CreateChildContextForSharedScript_ParentIsCallerExecution_FreshOwnId()
|
||||
{
|
||||
@@ -237,6 +248,7 @@ public class ParentExecutionTreeTests : TestKit
|
||||
Assert.NotEqual(bExecutionId, child.ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a nested <c>CallScript</c> from an alarm on-trigger script carries the alarm run's own execution id as parent.</summary>
|
||||
[Fact]
|
||||
public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent()
|
||||
{
|
||||
|
||||
+11
-1
@@ -7,7 +7,7 @@ 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
|
||||
/// 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.
|
||||
@@ -46,6 +46,8 @@ public class RecursionLimitSiteEventTests
|
||||
// CallScript
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that exceeding the CallScript recursion limit emits a script error site event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
|
||||
{
|
||||
@@ -76,6 +78,8 @@ public class RecursionLimitSiteEventTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exceeding the CallScript recursion limit with no site event logger configured does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_RecursionLimitExceeded_NullLogger_DoesNotThrow()
|
||||
{
|
||||
@@ -93,6 +97,8 @@ public class RecursionLimitSiteEventTests
|
||||
// CallShared (via Scripts.CallShared)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that exceeding the CallShared recursion limit emits a script error site event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallShared_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
|
||||
{
|
||||
@@ -121,6 +127,8 @@ public class RecursionLimitSiteEventTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exceeding the CallShared recursion limit with no site event logger configured does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallShared_RecursionLimitExceeded_NullLogger_DoesNotThrow()
|
||||
{
|
||||
@@ -136,6 +144,8 @@ public class RecursionLimitSiteEventTests
|
||||
// Verify the event shape precisely (category, severity, message content)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that the recursion-limit site event has the correct category and severity.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_RecursionLimitExceeded_EventHasCorrectCategoryAndSeverity()
|
||||
{
|
||||
|
||||
@@ -5,13 +5,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-6 (Phase 8): Script sandboxing verification.
|
||||
/// Script sandboxing verification.
|
||||
/// Adversarial tests that verify forbidden APIs are blocked at compilation time.
|
||||
/// </summary>
|
||||
public class SandboxTests
|
||||
{
|
||||
private readonly ScriptCompilationService _service;
|
||||
|
||||
/// <summary>Initializes the test class with a script compilation service.</summary>
|
||||
public SandboxTests()
|
||||
{
|
||||
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||
@@ -19,6 +20,7 @@ public class SandboxTests
|
||||
|
||||
// ── System.IO forbidden ──
|
||||
|
||||
/// <summary>Verifies that reading a file via System.IO is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_FileRead_Blocked()
|
||||
{
|
||||
@@ -27,6 +29,7 @@ public class SandboxTests
|
||||
Assert.Contains(result.Errors, e => e.Contains("System.IO"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing a file via System.IO is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_FileWrite_Blocked()
|
||||
{
|
||||
@@ -34,6 +37,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a directory via System.IO is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_DirectoryCreate_Blocked()
|
||||
{
|
||||
@@ -41,6 +45,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a FileStream is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_FileStream_Blocked()
|
||||
{
|
||||
@@ -48,6 +53,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a StreamReader is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_StreamReader_Blocked()
|
||||
{
|
||||
@@ -57,6 +63,7 @@ public class SandboxTests
|
||||
|
||||
// ── Process forbidden ──
|
||||
|
||||
/// <summary>Verifies that starting a process is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_ProcessStart_Blocked()
|
||||
{
|
||||
@@ -65,6 +72,7 @@ public class SandboxTests
|
||||
Assert.Contains(result.Errors, e => e.Contains("Process"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using ProcessStartInfo is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_ProcessStartInfo_Blocked()
|
||||
{
|
||||
@@ -78,6 +86,7 @@ public class SandboxTests
|
||||
|
||||
// ── Threading forbidden (except Tasks/CancellationToken) ──
|
||||
|
||||
/// <summary>Verifies that creating a thread is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_ThreadCreate_Blocked()
|
||||
{
|
||||
@@ -86,6 +95,7 @@ public class SandboxTests
|
||||
Assert.Contains(result.Errors, e => e.Contains("System.Threading"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a Mutex is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_Mutex_Blocked()
|
||||
{
|
||||
@@ -93,6 +103,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a Semaphore is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_Semaphore_Blocked()
|
||||
{
|
||||
@@ -100,6 +111,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Task.Delay is allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_TaskDelay_Allowed()
|
||||
{
|
||||
@@ -108,6 +120,7 @@ public class SandboxTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a CancellationToken is allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_CancellationToken_Allowed()
|
||||
{
|
||||
@@ -116,6 +129,7 @@ public class SandboxTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a CancellationTokenSource is allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_CancellationTokenSource_Allowed()
|
||||
{
|
||||
@@ -126,6 +140,7 @@ public class SandboxTests
|
||||
|
||||
// ── Reflection forbidden ──
|
||||
|
||||
/// <summary>Verifies that reflection via GetType is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_GetType_Reflection_Blocked()
|
||||
{
|
||||
@@ -134,6 +149,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading an assembly is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_AssemblyLoad_Blocked()
|
||||
{
|
||||
@@ -142,6 +158,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating an instance via Activator reflection is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_ActivatorCreateInstance_ViaReflection_Blocked()
|
||||
{
|
||||
@@ -152,6 +169,7 @@ public class SandboxTests
|
||||
|
||||
// ── Raw network forbidden ──
|
||||
|
||||
/// <summary>Verifies that using a TcpClient is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_TcpClient_Blocked()
|
||||
{
|
||||
@@ -160,6 +178,7 @@ public class SandboxTests
|
||||
Assert.Contains(result.Errors, e => e.Contains("System.Net.Sockets"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using a UdpClient is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_UdpClient_Blocked()
|
||||
{
|
||||
@@ -167,6 +186,7 @@ public class SandboxTests
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using an HttpClient is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_HttpClient_Blocked()
|
||||
{
|
||||
@@ -175,6 +195,7 @@ public class SandboxTests
|
||||
Assert.Contains(result.Errors, e => e.Contains("System.Net.Http"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that using an HttpRequestMessage is blocked at compilation.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_HttpRequestMessage_Blocked()
|
||||
{
|
||||
@@ -185,6 +206,7 @@ public class SandboxTests
|
||||
|
||||
// ── Allowed operations ──
|
||||
|
||||
/// <summary>Verifies that basic math operations are allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_BasicMath_Allowed()
|
||||
{
|
||||
@@ -192,6 +214,7 @@ public class SandboxTests
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LINQ operations are allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_LinqOperations_Allowed()
|
||||
{
|
||||
@@ -200,6 +223,7 @@ public class SandboxTests
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that string operations are allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_StringOperations_Allowed()
|
||||
{
|
||||
@@ -208,6 +232,7 @@ public class SandboxTests
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DateTime operations are allowed.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_DateTimeOperations_Allowed()
|
||||
{
|
||||
@@ -257,6 +282,8 @@ public class SandboxTests
|
||||
CancellationToken = cts.Token
|
||||
};
|
||||
|
||||
/// <summary>Verifies that an infinite loop is cancelled by the cancellation token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sandbox_InfiniteLoop_CancelledByToken()
|
||||
{
|
||||
@@ -284,6 +311,8 @@ public class SandboxTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a long-running script times out.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sandbox_LongRunningScript_TimesOut()
|
||||
{
|
||||
@@ -314,6 +343,8 @@ public class SandboxTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script that is never cancelled runs to completion.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sandbox_UncancelledScript_RunsToCompletion()
|
||||
{
|
||||
@@ -348,6 +379,7 @@ public class SandboxTests
|
||||
|
||||
// ── Combined adversarial attempts ──
|
||||
|
||||
/// <summary>Verifies that multiple violations in one script are all detected.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_MultipleViolationsInOneScript_AllDetected()
|
||||
{
|
||||
@@ -363,6 +395,7 @@ public class SandboxTests
|
||||
$"Expected at least 4 violations but got {violations.Count}: {string.Join("; ", violations)}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a forbidden using directive is still detected.</summary>
|
||||
[Fact]
|
||||
public void Sandbox_UsingDirective_StillDetected()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// </summary>
|
||||
public class ScopeAccessorTests
|
||||
{
|
||||
/// <summary>Verifies that the root scope has an empty self path.</summary>
|
||||
[Fact]
|
||||
public void Root_SelfPath_Empty()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public class ScopeAccessorTests
|
||||
Assert.False(ScriptScope.Root.HasParent);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a composition scope has a parent scope.</summary>
|
||||
[Fact]
|
||||
public void CompositionScope_HasParent()
|
||||
{
|
||||
@@ -32,6 +34,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("", scope.ParentPath);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an attribute accessor in the root scope resolves a bare key.</summary>
|
||||
[Fact]
|
||||
public void AttributeAccessor_RootScope_ResolvesBareKey()
|
||||
{
|
||||
@@ -39,6 +42,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("Temperature", acc.Resolve("Temperature"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an attribute accessor in a composed scope prepends the composition path.</summary>
|
||||
[Fact]
|
||||
public void AttributeAccessor_ComposedScope_PrependsPath()
|
||||
{
|
||||
@@ -46,6 +50,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("TempSensor.Temperature", acc.Resolve("Temperature"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an attribute accessor in a nested scope chains the full composition path.</summary>
|
||||
[Fact]
|
||||
public void AttributeAccessor_NestedScope_ChainsPath()
|
||||
{
|
||||
@@ -53,6 +58,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("Motor.TempSensor.Temperature", acc.Resolve("Temperature"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that attributes reached through a composition accessor share its scope.</summary>
|
||||
[Fact]
|
||||
public void CompositionAccessor_AttributesShareScope()
|
||||
{
|
||||
@@ -61,6 +67,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("TempSensor", comp.Attributes.ScopePrefix);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving a script through a composition accessor prepends the composition path.</summary>
|
||||
[Fact]
|
||||
public void CompositionAccessor_ResolveScript_PrependsPath()
|
||||
{
|
||||
@@ -68,6 +75,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("TempSensor.Sample", comp.ResolveScript("Sample"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a composition accessor with an empty path leaves the script name bare.</summary>
|
||||
[Fact]
|
||||
public void CompositionAccessor_EmptyPath_LeavesScriptNameBare()
|
||||
{
|
||||
@@ -75,6 +83,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("Sample", comp.ResolveScript("Sample"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a children accessor from the root scope returns a child with no path prefix.</summary>
|
||||
[Fact]
|
||||
public void ChildrenAccessor_FromRoot_GivesUnpathedChild()
|
||||
{
|
||||
@@ -83,6 +92,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("TempSensor", temp.Path);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a children accessor from a composition scope prefixes the child with the composition path.</summary>
|
||||
[Fact]
|
||||
public void ChildrenAccessor_FromComposition_PrefixesChild()
|
||||
{
|
||||
@@ -105,6 +115,7 @@ public class ScopeAccessorTests
|
||||
// These tests verify that contract at the codec level, which is exactly what
|
||||
// the fix makes the accessor invoke.
|
||||
|
||||
/// <summary>Verifies that encoding a list produces a JSON array.</summary>
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_List_ProducesJsonArray()
|
||||
{
|
||||
@@ -115,6 +126,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("[\"a\",\"b\"]", encoded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that encoding a scalar value passes it through unchanged.</summary>
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_Scalar_PassesThrough()
|
||||
{
|
||||
@@ -124,6 +136,7 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("x", encoded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that encoding null returns null.</summary>
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_Null_ReturnsNull()
|
||||
{
|
||||
@@ -132,10 +145,11 @@ public class ScopeAccessorTests
|
||||
Assert.Null(AttributeValueCodec.Encode(null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that encoding an integer list produces a JSON array of numbers.</summary>
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_IntList_ProducesJsonArray()
|
||||
{
|
||||
// Integer list elements encode as native-typed JSON numbers (NJ-1):
|
||||
// Integer list elements encode as native-typed JSON numbers:
|
||||
// [1,2,3], not the old quoted-element form ["1","2","3"].
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
var encoded = AttributeValueCodec.Encode(list);
|
||||
@@ -164,8 +178,10 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
instanceName: "Pump1",
|
||||
logger: NullLogger<ScriptRuntimeContext>.Instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that WaitAsync with a value applies scope resolution before sending the request.</summary>
|
||||
[Fact]
|
||||
public void WaitAsync_Value_AppliesScopeResolution_BeforeSendingRequest()
|
||||
{
|
||||
@@ -188,6 +204,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
Assert.Equal("Pump1", req.InstanceName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WaitAsync with a predicate applies scope resolution and sends the predicate.</summary>
|
||||
[Fact]
|
||||
public void WaitAsync_Predicate_AppliesScopeResolution_AndSendsPredicate()
|
||||
{
|
||||
@@ -206,6 +223,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
Assert.NotNull(req.Predicate);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WaitAsync in the root scope leaves the key bare.</summary>
|
||||
[Fact]
|
||||
public void WaitAsync_RootScope_LeavesKeyBare()
|
||||
{
|
||||
@@ -221,6 +239,8 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
|
||||
// ── WaitForAsync (spec §3): scope resolution + populated WaitResult ───────
|
||||
|
||||
/// <summary>Verifies that WaitForAsync with a value applies scope resolution and surfaces the populated wait result.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAsync_Value_AppliesScopeResolution_AndSurfacesPopulatedWaitResult()
|
||||
{
|
||||
@@ -250,6 +270,8 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WaitForAsync with a predicate applies scope resolution and surfaces the wait result.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAsync_Predicate_AppliesScopeResolution_AndSurfacesWaitResult()
|
||||
{
|
||||
@@ -274,6 +296,8 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
Assert.Equal(42, result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WaitForAsync passes the require-good-quality flag through into the request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForAsync_RequireGoodQuality_ThreadsFlagIntoRequest()
|
||||
{
|
||||
@@ -295,7 +319,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
// ── Reset-aware WriteBatchAndWaitAsync overload (Gitea #36) ──────────────
|
||||
// ── Reset-aware WriteBatchAndWaitAsync overload ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// The reset-aware overload must scope-resolve and codec-encode the reset key/value
|
||||
@@ -304,6 +328,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
/// <see cref="HandshakeResult.ResetFailed"/> result WITHOUT writing the batch or
|
||||
/// the trigger (the ExpectNoMsg at the end is that assertion).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout()
|
||||
{
|
||||
@@ -343,7 +368,7 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36 review: a null <c>resetValue</c> would encode to null, which writes an empty
|
||||
/// A null <c>resetValue</c> would encode to null, which writes an empty
|
||||
/// string on leg 1 and then hands the waiter a null target — "any change" — which fast-paths
|
||||
/// on the stale cached value and reinstates exactly the race this overload exists to close.
|
||||
/// The accessor rejects it before anything reaches the InstanceActor.
|
||||
|
||||
+24
-3
@@ -4,9 +4,9 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-19: Script Trust Model tests — validates forbidden API detection and compilation.
|
||||
/// Script Trust Model tests — validates forbidden API detection and compilation.
|
||||
///
|
||||
/// As of the M3.3 consolidation, <c>ScriptCompilationService.ValidateTrustModel</c>
|
||||
/// As of the consolidation, <c>ScriptCompilationService.ValidateTrustModel</c>
|
||||
/// delegates its forbidden-API verdict to the shared authoritative
|
||||
/// <c>ScriptAnalysis.ScriptTrustValidator</c>, which is stricter than SiteRuntime's
|
||||
/// original deny-list: ALL of <c>System.Net</c> is forbidden (not just Sockets/Http),
|
||||
@@ -26,11 +26,13 @@ public class ScriptCompilationServiceTests
|
||||
private readonly SiteScriptCompileCache _cache = new();
|
||||
private readonly ScriptCompilationService _service;
|
||||
|
||||
/// <summary>Initializes the test class with a compilation service.</summary>
|
||||
public ScriptCompilationServiceTests()
|
||||
{
|
||||
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance, _cache);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compiling a valid script succeeds.</summary>
|
||||
[Fact]
|
||||
public void Compile_ValidScript_Succeeds()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compiling the same code twice shares a single Roslyn compile.</summary>
|
||||
[Fact]
|
||||
public void Compile_SameCodeTwice_SharesOneRoslynCompile()
|
||||
{
|
||||
@@ -55,6 +58,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Equal(1, _cache.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script and a trigger expression with the same code do not cross-contaminate.</summary>
|
||||
[Fact]
|
||||
public void Compile_ScriptAndTriggerExpression_DoNotCrossContaminate()
|
||||
{
|
||||
@@ -66,6 +70,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Equal(0, _cache.Hits);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compiling invalid syntax returns errors.</summary>
|
||||
[Fact]
|
||||
public void Compile_InvalidSyntax_ReturnsErrors()
|
||||
{
|
||||
@@ -74,6 +79,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that System.IO is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_SystemIO_Forbidden()
|
||||
{
|
||||
@@ -82,6 +88,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Contains(violations, v => v.Contains("System.IO"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Process is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Process_Forbidden()
|
||||
{
|
||||
@@ -90,6 +97,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Reflection is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Reflection_Forbidden()
|
||||
{
|
||||
@@ -98,6 +106,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Sockets are forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Sockets_Forbidden()
|
||||
{
|
||||
@@ -106,6 +115,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HttpClient is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_HttpClient_Forbidden()
|
||||
{
|
||||
@@ -114,6 +124,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that async and await are allowed by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_AsyncAwait_Allowed()
|
||||
{
|
||||
@@ -123,6 +134,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CancellationToken is allowed by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_CancellationToken_Allowed()
|
||||
{
|
||||
@@ -131,6 +143,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clean code produces no trust model violations.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_CleanCode_NoViolations()
|
||||
{
|
||||
@@ -143,6 +156,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compiling a script that uses a forbidden API fails validation.</summary>
|
||||
[Fact]
|
||||
public void Compile_ForbiddenApi_FailsValidation()
|
||||
{
|
||||
@@ -151,8 +165,9 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
// ── M3.3: stricter shared-validator behavior ──
|
||||
// ── Stricter shared-validator behavior ──
|
||||
|
||||
/// <summary>Verifies that System.Net.Dns is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_SystemNetDns_Forbidden()
|
||||
{
|
||||
@@ -164,6 +179,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Contains(violations, v => v.Contains("System.Net"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reflection gateway reached through a permitted type is forbidden.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_ReflectionGatewayViaPermittedType_Forbidden()
|
||||
{
|
||||
@@ -175,6 +191,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dynamic is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Dynamic_Forbidden()
|
||||
{
|
||||
@@ -182,6 +199,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Activator is forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Activator_Forbidden()
|
||||
{
|
||||
@@ -190,6 +208,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that InteropServices are forbidden by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_InteropServices_Forbidden()
|
||||
{
|
||||
@@ -198,6 +217,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Stopwatch is allowed by the trust model.</summary>
|
||||
[Fact]
|
||||
public void ValidateTrustModel_Stopwatch_Allowed()
|
||||
{
|
||||
@@ -255,6 +275,7 @@ public class ScriptCompilationServiceTests
|
||||
Assert.Same(first.CompiledScript.Options, second.CompiledScript.Options);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compilation uses the process-wide caching metadata resolver.</summary>
|
||||
[Fact]
|
||||
public void Compile_UsesProcessWideCachingMetadataResolver()
|
||||
{
|
||||
|
||||
+12
-3
@@ -4,12 +4,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-009: the dedicated script-execution scheduler must run script bodies on
|
||||
/// The dedicated script-execution scheduler must run script bodies on
|
||||
/// its own dedicated threads, not on the shared .NET thread pool, so blocking script
|
||||
/// I/O cannot starve the global pool.
|
||||
/// </summary>
|
||||
public class ScriptExecutionSchedulerTests
|
||||
{
|
||||
/// <summary>Verifies that the scheduler does not inline work on another scheduler's worker thread.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Scheduler_DoesNotInlineOnAnotherSchedulersWorker()
|
||||
{
|
||||
@@ -24,6 +26,8 @@ public class ScriptExecutionSchedulerTests
|
||||
}, CancellationToken.None, TaskCreationOptions.None, first);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the scheduler runs work off the shared thread pool.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Scheduler_RunsWork_OffTheThreadPool()
|
||||
{
|
||||
@@ -43,6 +47,7 @@ public class ScriptExecutionSchedulerTests
|
||||
Assert.StartsWith("script-execution-", threadName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the scheduler respects the configured thread count.</summary>
|
||||
[Fact]
|
||||
public void Scheduler_RespectsConfiguredThreadCount()
|
||||
{
|
||||
@@ -50,6 +55,7 @@ public class ScriptExecutionSchedulerTests
|
||||
Assert.Equal(4, scheduler.MaximumConcurrencyLevel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the shared scheduler returns the same instance for the same options.</summary>
|
||||
[Fact]
|
||||
public void Scheduler_Shared_ReturnsSameInstanceForOptions()
|
||||
{
|
||||
@@ -59,11 +65,12 @@ public class ScriptExecutionSchedulerTests
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
|
||||
// Gitea #18: IsDisposed backs the Shared() recreate guard so a disposed scheduler
|
||||
// IsDisposed backs the Shared() recreate guard so a disposed scheduler
|
||||
// can never be handed back and silently poison every later execution. Verified on a
|
||||
// local instance — deliberately NOT by disposing the process-wide singleton, which
|
||||
// would strand any script another test class queued on it (the very static hazard
|
||||
// #18 is about).
|
||||
// this test guards against).
|
||||
/// <summary>Verifies that <c>IsDisposed</c> flips to true when the scheduler is disposed.</summary>
|
||||
[Fact]
|
||||
public void IsDisposed_FlipsOnDispose()
|
||||
{
|
||||
@@ -79,6 +86,8 @@ public class ScriptExecutionSchedulerTests
|
||||
|
||||
// SiteRuntime-S2/UA5: the scheduler exposes observability gauges so a stuck /
|
||||
// saturated script-execution pool is visible to operators via site health.
|
||||
/// <summary>Verifies that the gauges reflect the busy thread count and the queue depth.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Gauges_ReflectBusyThreadAndQueueDepth()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
private readonly string _base;
|
||||
private readonly string _root;
|
||||
|
||||
/// <summary>Initializes the tests with a temporary root directory.</summary>
|
||||
public ScriptFileReaderTests()
|
||||
{
|
||||
_base = Path.Combine(Path.GetTempPath(), "sb-files-" + Guid.NewGuid().ToString("N"));
|
||||
@@ -26,6 +27,7 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_base, recursive: true); } catch { /* best effort */ }
|
||||
@@ -52,6 +54,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
private static async Task<ScriptFileAccessFailure> FailureOf(Func<Task> act) =>
|
||||
(await Assert.ThrowsAsync<ScriptFileAccessException>(act)).Failure;
|
||||
|
||||
/// <summary>Verifies that file access is disabled by default when no roots are configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoRoots_IsDisabled_ByDefault()
|
||||
{
|
||||
@@ -62,6 +66,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ExistsAsync(path, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that text under a root is read and a byte order mark is detected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadsText_UnderRoot_AndDetectsBom()
|
||||
{
|
||||
@@ -73,6 +79,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal("ReactorTempSP,1050\n", text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a file in a nested directory under the root can be read.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadsFromNestedDirectory()
|
||||
{
|
||||
@@ -80,6 +88,9 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal("a,1", await Reader().ReadTextAsync(path, default));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty or relative path is rejected.</summary>
|
||||
/// <param name="path">The empty or relative path to read.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -89,6 +100,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal(ScriptFileAccessFailure.InvalidPath, await FailureOf(() => Reader().ReadTextAsync(path, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a path outside the configured root is rejected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RejectsPathOutsideRoot()
|
||||
{
|
||||
@@ -99,6 +112,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ReadTextAsync(outside, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a dot-dot traversal out of the root is rejected.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RejectsDotDotTraversalOutOfRoot()
|
||||
{
|
||||
@@ -109,6 +124,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ReadTextAsync(sneaky, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a sibling directory sharing the root's name prefix is not treated as inside the root.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SiblingDirectorySharingThePrefix_IsNotInsideTheRoot()
|
||||
{
|
||||
@@ -122,6 +139,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ReadTextAsync(path, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the root directory itself is not treated as a file.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TheRootItself_IsNotAFile()
|
||||
{
|
||||
@@ -129,6 +148,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ReadTextAsync(_root, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a symlinked file inside the root is refused.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SymlinkedFileInsideRoot_IsRefused()
|
||||
{
|
||||
@@ -141,6 +162,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ReadTextAsync(link, default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a symlinked directory inside the root is refused.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SymlinkedDirectoryInsideRoot_IsRefused()
|
||||
{
|
||||
@@ -153,6 +176,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ReadTextAsync(Path.Combine(_root, "linked", "secret.txt"), default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing file reports not found and that the existence check returns false.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MissingFile_IsNotFound_AndExistsIsFalse()
|
||||
{
|
||||
@@ -162,6 +187,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.False(await Reader().ExistsAsync(path, default));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the existence check returns true for a file but still enforces the roots.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Exists_TrueForFile_ButStillEnforcesRoots()
|
||||
{
|
||||
@@ -171,6 +198,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await FailureOf(() => Reader().ExistsAsync(Path.Combine(_base, "whatever.txt"), default)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a file over the size cap is reported as too large.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FileOverTheCap_IsTooLarge()
|
||||
{
|
||||
@@ -180,6 +209,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal(2048, (await Reader(maxBytes: 2048).ReadTextAsync(path, default)).Length);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a file under a second configured root can be read.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SecondRoot_IsHonoured()
|
||||
{
|
||||
@@ -191,6 +222,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal("ok", await Reader(1024 * 1024, _root, other).ReadTextAsync(path, default));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a root configured with a trailing separator works.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RootConfiguredWithTrailingSeparator_Works()
|
||||
{
|
||||
@@ -198,6 +231,7 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal("ok", await Reader(1024 * 1024, _root + Path.DirectorySeparatorChar).ReadTextAsync(path, default));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that line splitting handles mixed line endings and drops a trailing empty line.</summary>
|
||||
[Fact]
|
||||
public void SplitLines_HandlesMixedEndings_AndDropsTrailingEmptyLine()
|
||||
{
|
||||
@@ -206,6 +240,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal([""], FilesAccessor.SplitLines("\n"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the script accessor throws a disabled error when no reader is configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Accessor_WithoutReader_ThrowsDisabled()
|
||||
{
|
||||
@@ -213,6 +249,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => files.ReadTextAsync("C:/x.txt")));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the script accessor honours the script deadline.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Accessor_HonoursTheScriptDeadline()
|
||||
{
|
||||
@@ -224,6 +262,8 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => files.ReadTextAsync(path));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a real script compiles through the trust gate and parses a recipe file.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RealScript_CompilesThroughTheTrustGate_AndParsesARecipe()
|
||||
{
|
||||
@@ -263,6 +303,7 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
Assert.Equal("missing", await run(Path.Combine(_root, "nope.txt")));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that raw System.IO usage is still rejected by the trust gate.</summary>
|
||||
[Fact]
|
||||
public void RawSystemIo_IsStillRejectedByTheTrustGate()
|
||||
{
|
||||
@@ -274,8 +315,15 @@ public sealed class ScriptFileReaderTests : IDisposable
|
||||
|
||||
private sealed class StaticMonitor(SiteRuntimeOptions value) : IOptionsMonitor<SiteRuntimeOptions>
|
||||
{
|
||||
/// <summary>Gets the fixed options instance.</summary>
|
||||
public SiteRuntimeOptions CurrentValue => value;
|
||||
/// <summary>Returns the fixed options instance regardless of name.</summary>
|
||||
/// <param name="name">The options name, ignored.</param>
|
||||
/// <returns>The fixed options instance.</returns>
|
||||
public SiteRuntimeOptions Get(string? name) => value;
|
||||
/// <summary>Ignores the listener because the options never change.</summary>
|
||||
/// <param name="listener">The change listener, ignored.</param>
|
||||
/// <returns>Always <see langword="null"/>.</returns>
|
||||
public IDisposable? OnChange(Action<SiteRuntimeOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,27 @@ public class ScriptLoggerTests
|
||||
/// <summary>A minimal recording <see cref="ILogger"/> — clearer here than a mocking framework.</summary>
|
||||
private sealed class RecordingLogger(bool enabled = true) : ILogger
|
||||
{
|
||||
/// <summary>Gets the entries recorded so far.</summary>
|
||||
public List<LogEntry> Entries { get; } = [];
|
||||
|
||||
/// <summary>Returns no scope because the fake does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||
/// <param name="state">The scope or log state.</param>
|
||||
/// <returns>A no-op scope.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports whether logging is enabled for the given level.</summary>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <returns>True if the level is enabled; otherwise false.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => enabled;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||
/// <param name="level">The log level.</param>
|
||||
/// <param name="eventId">The event id.</param>
|
||||
/// <param name="state">The scope or log state.</param>
|
||||
/// <param name="exception">The exception associated with the entry, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message text.</param>
|
||||
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add(new LogEntry(level, formatter(state, exception), exception));
|
||||
}
|
||||
@@ -47,6 +62,7 @@ public class ScriptLoggerTests
|
||||
return (logger, node, rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced run forwards to the node logger and emits no row.</summary>
|
||||
[Fact]
|
||||
public void UntracedRun_ForwardsToTheNodeLogger_AndEmitsNoRow()
|
||||
{
|
||||
@@ -64,6 +80,7 @@ public class ScriptLoggerTests
|
||||
Assert.Equal(LogLevel.Warning, node.Entries[1].Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced run emits one script-sourced row per line.</summary>
|
||||
[Fact]
|
||||
public void TracedRun_EmitsOneScriptSourcedRowPerLine()
|
||||
{
|
||||
@@ -76,6 +93,7 @@ public class ScriptLoggerTests
|
||||
Assert.Equal(("info", "script", "msg", (Exception?)null, (Guid?)null), (row.Level, row.Source, row.Message, row.Exception, row.StepEventId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every log level maps to its wire name.</summary>
|
||||
[Fact]
|
||||
public void EveryLevel_MapsToItsWireName()
|
||||
{
|
||||
@@ -92,6 +110,7 @@ public class ScriptLoggerTests
|
||||
node.Entries.Select(e => e.Level));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a line inside a step scope is attributed to that step and the scope unwinds afterwards.</summary>
|
||||
[Fact]
|
||||
public void LineInsideAStepScope_IsAttributedToThatStep_AndTheScopeUnwinds()
|
||||
{
|
||||
@@ -112,6 +131,7 @@ public class ScriptLoggerTests
|
||||
Assert.Null(rows[1].StepEventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nested step scopes unwind to the enclosing step.</summary>
|
||||
[Fact]
|
||||
public void NestedStepScopes_Unwind_ToTheEnclosingStep()
|
||||
{
|
||||
@@ -138,6 +158,7 @@ public class ScriptLoggerTests
|
||||
Assert.Equal(outer, rows[1].StepEventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that lines past the per-run cap are dropped and flushing names the dropped count exactly once.</summary>
|
||||
[Fact]
|
||||
public void PastThePerRunCap_LinesAreDropped_AndFlushDroppedNamesTheCount_Once()
|
||||
{
|
||||
@@ -159,7 +180,7 @@ public class ScriptLoggerTests
|
||||
var closing = rows[^1];
|
||||
Assert.Equal("warn", closing.Level);
|
||||
Assert.Equal("runtime", closing.Source);
|
||||
// M3: worded identically to the central InboundScriptLogger closing row.
|
||||
// Worded identically to the central InboundScriptLogger closing row.
|
||||
Assert.Contains("2 further log line(s) were not captured", closing.Message);
|
||||
Assert.Contains("ScadaBridge:SiteRuntime:MaxLogLinesPerRun = 3", closing.Message);
|
||||
Assert.Null(closing.StepEventId);
|
||||
@@ -169,6 +190,7 @@ public class ScriptLoggerTests
|
||||
Assert.Equal(4, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that flushing emits nothing when no line was dropped.</summary>
|
||||
[Fact]
|
||||
public void FlushDropped_EmitsNothing_WhenNoLineWasDropped()
|
||||
{
|
||||
@@ -180,6 +202,7 @@ public class ScriptLoggerTests
|
||||
Assert.Single(rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that messages longer than the cap are clipped with an ellipsis.</summary>
|
||||
[Fact]
|
||||
public void MessagesLongerThanTheCap_AreClippedWithAnEllipsis()
|
||||
{
|
||||
@@ -192,6 +215,7 @@ public class ScriptLoggerTests
|
||||
Assert.EndsWith("…", message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a message exactly at the cap passes through unclipped.</summary>
|
||||
[Fact]
|
||||
public void AMessageExactlyAtTheCap_PassesThroughUnclipped()
|
||||
{
|
||||
@@ -203,6 +227,7 @@ public class ScriptLoggerTests
|
||||
Assert.Equal(exact, Assert.Single(rows).Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a surrogate pair straddling the cap is dropped whole.</summary>
|
||||
[Fact]
|
||||
public void ASurrogatePairStraddlingTheCap_IsDroppedWhole()
|
||||
{
|
||||
@@ -219,6 +244,7 @@ public class ScriptLoggerTests
|
||||
Assert.DoesNotContain(message, static c => char.IsSurrogate(c));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing emitter never reaches the script and the node logger still gets the line.</summary>
|
||||
[Fact]
|
||||
public void AThrowingEmitter_NeverReachesTheScript_AndTheNodeLoggerStillGotTheLine()
|
||||
{
|
||||
@@ -238,6 +264,7 @@ public class ScriptLoggerTests
|
||||
Assert.Contains("still logged", node.Entries[0].Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing node logger never reaches the script and the row is still emitted.</summary>
|
||||
[Fact]
|
||||
public void AThrowingNodeLogger_NeverReachesTheScript_AndTheRowIsStillEmitted()
|
||||
{
|
||||
@@ -258,14 +285,29 @@ public class ScriptLoggerTests
|
||||
/// <summary>A node logger whose sink fails, standing in for a wedged Serilog sink.</summary>
|
||||
private sealed class ThrowingLogger : ILogger
|
||||
{
|
||||
/// <summary>Returns no scope because the fake does not track scopes.</summary>
|
||||
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||
/// <param name="state">The scope or log state.</param>
|
||||
/// <returns>A no-op scope.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports whether logging is enabled for the given level.</summary>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <returns>True if the level is enabled; otherwise false.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Throws to simulate a failing log sink.</summary>
|
||||
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||
/// <param name="level">The log level.</param>
|
||||
/// <param name="eventId">The event id.</param>
|
||||
/// <param name="state">The scope or log state.</param>
|
||||
/// <param name="exception">The exception associated with the entry, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into the message text.</param>
|
||||
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
throw new InvalidOperationException("sink is down");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an attached exception reaches both the node logger and the row.</summary>
|
||||
[Fact]
|
||||
public void AnAttachedException_ReachesBothTheNodeLoggerAndTheRow()
|
||||
{
|
||||
@@ -278,6 +320,7 @@ public class ScriptLoggerTests
|
||||
Assert.Same(boom, Assert.Single(rows).Exception);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that runtime lines are row-only and carry the runtime source.</summary>
|
||||
[Fact]
|
||||
public void RuntimeLines_AreRowOnly_AndCarryTheRuntimeSource()
|
||||
{
|
||||
@@ -293,6 +336,7 @@ public class ScriptLoggerTests
|
||||
Assert.Equal("runtime", row.Source);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that runtime lines share the per-run cap with script lines.</summary>
|
||||
[Fact]
|
||||
public void RuntimeLines_ShareThePerRunCapWithScriptLines()
|
||||
{
|
||||
@@ -307,6 +351,7 @@ public class ScriptLoggerTests
|
||||
Assert.Contains("1 further log line(s) were not captured", rows[^1].Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the maximum line count must be positive.</summary>
|
||||
[Fact]
|
||||
public void MaxLinesMustBePositive()
|
||||
{
|
||||
@@ -314,6 +359,7 @@ public class ScriptLoggerTests
|
||||
new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 0, "i", "s", null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the node logger is required.</summary>
|
||||
[Fact]
|
||||
public void TheNodeLoggerIsRequired()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 test group 5 — the blocking script pool is no longer a fixed 8 threads forever. It
|
||||
/// The blocking script pool is no longer a fixed 8 threads forever. It
|
||||
/// scales with the number of running instances between a configured floor and ceiling, and it
|
||||
/// is deliberately GROW-ONLY: undeploying instances leaves idle threads (which cost nothing
|
||||
/// measurable) rather than paying for drain/steal complexity.
|
||||
@@ -17,8 +17,11 @@ public class ScriptPoolSizingTests
|
||||
ScriptExecutionMaxThreadCount = ceiling
|
||||
};
|
||||
|
||||
/// <summary>Verifies that the target thread count applies the floor, ratio and ceiling.</summary>
|
||||
/// <param name="instances">The number of running instances.</param>
|
||||
/// <param name="expected">The expected target thread count.</param>
|
||||
[Theory]
|
||||
// At or below floor * 8 instances the result is exactly the pre-WP3.1 fixed size —
|
||||
// At or below floor * 8 instances the result is exactly the original fixed size —
|
||||
// existing configurations are byte-for-byte unchanged in behaviour.
|
||||
[InlineData(0, 8)]
|
||||
[InlineData(1, 8)]
|
||||
@@ -33,6 +36,7 @@ public class ScriptPoolSizingTests
|
||||
public void ComputeTargetThreads_AppliesFloorRatioAndCeiling(int instances, int expected)
|
||||
=> Assert.Equal(expected, ScriptExecutionScheduler.ComputeTargetThreads(instances, Options()));
|
||||
|
||||
/// <summary>Verifies that an overridden floor and ceiling are honoured.</summary>
|
||||
[Fact]
|
||||
public void ComputeTargetThreads_HonoursAnOverriddenFloorAndCeiling()
|
||||
{
|
||||
@@ -43,6 +47,7 @@ public class ScriptPoolSizingTests
|
||||
Assert.Equal(4, ScriptExecutionScheduler.ComputeTargetThreads(1000, options));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the target thread count is never less than one, even with a degenerate floor.</summary>
|
||||
[Fact]
|
||||
public void ComputeTargetThreads_NeverReturnsLessThanOne_EvenWithADegenerateFloor()
|
||||
{
|
||||
@@ -53,6 +58,7 @@ public class ScriptPoolSizingTests
|
||||
Assert.Equal(1, ScriptExecutionScheduler.ComputeTargetThreads(500, options));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that EnsureCapacity grows once, is idempotent and never shrinks.</summary>
|
||||
[Fact]
|
||||
public void EnsureCapacity_GrowsOnce_IsIdempotent_AndNeverShrinks()
|
||||
{
|
||||
@@ -71,6 +77,8 @@ public class ScriptPoolSizingTests
|
||||
Assert.Equal(5, scheduler.MaximumConcurrencyLevel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that EnsureCapacity widens the gauges so the whole pool is observable.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureCapacity_WidensTheGauges_SoTheWholePoolIsObservable()
|
||||
{
|
||||
|
||||
@@ -3,8 +3,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md,
|
||||
/// stage (b) test plan items 1–3): pure unit tests for
|
||||
/// Site-events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
|
||||
/// pure unit tests for
|
||||
/// <see cref="ScriptRunEventPolicy.ShouldEmitPerRun"/>, isolated from any actor or DI
|
||||
/// plumbing — the actor-level wiring (hot toggle, hosted-service registration) is covered by
|
||||
/// <c>ScriptRunLauncherParityTests</c>.
|
||||
@@ -19,12 +19,14 @@ public class ScriptRunEventPolicyTests
|
||||
PerRunScriptEventScripts = (perRunScriptEventScripts ?? []).ToList()
|
||||
};
|
||||
|
||||
/// <summary>Verifies that the default options emit no per-run events.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_EmitsNothing()
|
||||
{
|
||||
Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(Options(), "Inst1", "Runner"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that turning the global switch on emits for every script.</summary>
|
||||
[Fact]
|
||||
public void GlobalSwitchOn_EmitsForEveryScript()
|
||||
{
|
||||
@@ -34,6 +36,7 @@ public class ScriptRunEventPolicyTests
|
||||
Assert.True(ScriptRunEventPolicy.ShouldEmitPerRun(options, "AnyOtherInstance", "AnyOtherScript"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an exact per-script match emits.</summary>
|
||||
[Fact]
|
||||
public void ExactPerScriptMatch_Emits()
|
||||
{
|
||||
@@ -42,6 +45,7 @@ public class ScriptRunEventPolicyTests
|
||||
Assert.True(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst1", "Runner"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an exact per-script match does not match a different script on the same instance.</summary>
|
||||
[Fact]
|
||||
public void ExactPerScriptMatch_DoesNotMatchADifferentScriptOnTheSameInstance()
|
||||
{
|
||||
@@ -50,6 +54,7 @@ public class ScriptRunEventPolicyTests
|
||||
Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst1", "OtherScript"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an exact per-script match does not match the same script name on a different instance.</summary>
|
||||
[Fact]
|
||||
public void ExactPerScriptMatch_DoesNotMatchTheSameScriptNameOnADifferentInstance()
|
||||
{
|
||||
@@ -58,6 +63,7 @@ public class ScriptRunEventPolicyTests
|
||||
Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst2", "Runner"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an instance wildcard emits for every script on that instance only.</summary>
|
||||
[Fact]
|
||||
public void InstanceWildcard_EmitsForEveryScriptOnThatInstanceOnly()
|
||||
{
|
||||
@@ -68,6 +74,7 @@ public class ScriptRunEventPolicyTests
|
||||
Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst2", "Runner"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script that does not match the opt-in list stays silent.</summary>
|
||||
[Fact]
|
||||
public void NonMatchingScript_StaysSilent()
|
||||
{
|
||||
@@ -76,6 +83,7 @@ public class ScriptRunEventPolicyTests
|
||||
Assert.False(ScriptRunEventPolicy.ShouldEmitPerRun(options, "Inst3", "Runner"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty opt-in list stays silent.</summary>
|
||||
[Fact]
|
||||
public void EmptyOptInList_StaysSilent()
|
||||
{
|
||||
|
||||
+6
-1
@@ -22,15 +22,17 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// </summary>
|
||||
public class ScriptRunLauncherTraceTests : TestKit, IDisposable
|
||||
{
|
||||
/// <summary>Own pool per class (#18 seam), so a wedged body cannot strand the process-wide one.</summary>
|
||||
/// <summary>Own pool per class, so a wedged body cannot strand the process-wide one.</summary>
|
||||
private readonly ScriptExecutionScheduler _scheduler = new(4);
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
|
||||
/// <summary>Initializes the test class with its script scheduler and shared script library.</summary>
|
||||
public ScriptRunLauncherTraceTests()
|
||||
=> _sharedLibrary = new SharedScriptLibrary(
|
||||
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
|
||||
NullLogger<SharedScriptLibrary>.Instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -98,6 +100,7 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable
|
||||
|
||||
// ── (a) a throwing run carries the exception's full detail ───────────────────
|
||||
|
||||
/// <summary>Verifies that a traced run that throws records the full exception detail on its script-run row.</summary>
|
||||
[Fact]
|
||||
public void ThrowingTracedRun_ScriptRunRow_CarriesTheFullExceptionDetail()
|
||||
{
|
||||
@@ -115,6 +118,7 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable
|
||||
|
||||
// ── (b) a timeout carries NO detail (the stack would be the launcher's) ──────
|
||||
|
||||
/// <summary>Verifies that a traced run that times out records no exception detail on its script-run row.</summary>
|
||||
[Fact]
|
||||
public void TimedOutTracedRun_ScriptRunRow_CarriesNoExceptionDetail()
|
||||
{
|
||||
@@ -131,6 +135,7 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable
|
||||
|
||||
// ── (c) the per-run line cap, its closing row, and emission order ────────────
|
||||
|
||||
/// <summary>Verifies that a traced run caps its log lines and emits the closing row before the anchor row.</summary>
|
||||
[Fact]
|
||||
public void TracedRun_CapsLogLines_AndEmitsTheClosingRowBeforeTheAnchor()
|
||||
{
|
||||
|
||||
+18
-2
@@ -6,12 +6,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md,
|
||||
/// stage (b) test plan item 5): <see cref="ScriptRunSummaryRecorder"/> in isolation — no
|
||||
/// Site-events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
|
||||
/// <see cref="ScriptRunSummaryRecorder"/> in isolation — no
|
||||
/// actor, no hosted service, just the accumulate/flush contract.
|
||||
/// </summary>
|
||||
public class ScriptRunSummaryRecorderTests
|
||||
{
|
||||
/// <summary>Verifies that flushing with no activity emits no row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ZeroActivity_FlushEmitsNoRow()
|
||||
{
|
||||
@@ -24,6 +26,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Empty(siteLog.Entries);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that flushing mixed outcomes emits one row with the correct totals.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MixedOutcomes_FlushEmitsOneRowWithCorrectTotals()
|
||||
{
|
||||
@@ -63,6 +67,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Equal(3, scripts.GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the counters reset after a flush.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CountersReset_AfterFlush()
|
||||
{
|
||||
@@ -81,6 +87,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Empty(secondLog.Entries);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that more than fifty scripts cap the breakdown and roll up the remainder.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MoreThanFiftyScripts_CapsBreakdownAndRollsUpTheRest()
|
||||
{
|
||||
@@ -122,6 +130,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Equal(60, top.GetProperty("started").GetInt64());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent increments are race-free.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentIncrements_AreRaceFree()
|
||||
{
|
||||
@@ -149,6 +159,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Contains($"{expectedTotal} completed", row.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a run spanning a flush boundary still reports its completion in the next window.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunSpanningAFlushBoundary_StillReportsItsCompletionInTheNextWindow()
|
||||
{
|
||||
@@ -183,6 +195,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Equal(42, script.GetProperty("maxDurationMs").GetInt64());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a completion-only entry does not undo idle flush suppression.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CompletionOnlyEntry_DoesNotResurrectTheIdleFlushSuppression()
|
||||
{
|
||||
@@ -198,6 +212,8 @@ public class ScriptRunSummaryRecorderTests
|
||||
Assert.Empty(idleLog.Entries);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duration tracking reports the average and maximum.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DurationTracking_ReportsAverageAndMax()
|
||||
{
|
||||
|
||||
+63
-3
@@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// emits <c>ScriptTrace</c> rows for attribute writes and waits (into the inline collector
|
||||
/// AND the audit writer), stamps <c>Trace</c> on the commands it sends, and inherits the flag
|
||||
/// into nested calls. An untraced context is byte-for-byte the pre-feature behaviour.
|
||||
/// Outbound-calls addendum (plan 2026-09-06 Task 3): the <c>ApiOutbound</c> /
|
||||
/// Outbound calls: the <c>ApiOutbound</c> /
|
||||
/// <c>DbOutbound</c> / <c>Notification</c> rows a run emits ride the same collector — the
|
||||
/// SAME <see cref="AuditEvent"/> instance the writer / telemetry forwarder receives — and
|
||||
/// nothing lands after the collector is <see cref="FlowTraceCollector.Complete"/>d.
|
||||
@@ -35,7 +35,9 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
/// <summary>The audit events written so far.</summary>
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events) Events.Add(evt);
|
||||
@@ -45,7 +47,9 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
|
||||
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
|
||||
{
|
||||
/// <summary>The cached-call telemetry packets forwarded so far.</summary>
|
||||
public List<CachedCallTelemetry> Telemetry { get; } = new();
|
||||
/// <inheritdoc />
|
||||
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||
{
|
||||
Telemetry.Add(telemetry);
|
||||
@@ -58,6 +62,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
private sealed class TempStoreAndForward : IDisposable
|
||||
{
|
||||
private readonly TestLocalDb _localDb;
|
||||
/// <summary>The store-and-forward service under test.</summary>
|
||||
public StoreAndForwardService Service { get; }
|
||||
|
||||
private TempStoreAndForward(TestLocalDb localDb, StoreAndForwardService service)
|
||||
@@ -66,6 +71,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Service = service;
|
||||
}
|
||||
|
||||
/// <summary>Creates a store-and-forward service over a temporary LocalDb file.</summary>
|
||||
/// <returns>A task that resolves to the new temporary store-and-forward wrapper.</returns>
|
||||
public static async Task<TempStoreAndForward> CreateAsync()
|
||||
{
|
||||
var localDb = TestLocalDb.CreateTemp("TraceEmissionNotify");
|
||||
@@ -81,6 +88,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
new StoreAndForwardService(storage, options, NullLogger<StoreAndForwardService>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_localDb.Dispose();
|
||||
@@ -159,6 +167,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
isTraced: traced, traceCollector: collector,
|
||||
maxLogLinesPerRun: maxLogLinesPerRun);
|
||||
|
||||
/// <summary>Verifies that a traced SetAttribute emits an attribute-write row and passes the trace flag on the command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand()
|
||||
{
|
||||
@@ -194,6 +204,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Same(evt, Assert.Single(collector.Snapshot()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced SetAttribute emits nothing and sends a command with the trace flag false.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_WhenNotTraced_EmitsNothing_AndCommandTraceFalse()
|
||||
{
|
||||
@@ -216,6 +228,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
/// carries is the FILTERED record — while the writer still receives the raw event and
|
||||
/// redacts it itself (redaction is idempotent, so the stored row is identical).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_WhenTraced_InlineCopyIsRedacted_WriterStillSeesTheRawEvent()
|
||||
{
|
||||
@@ -240,10 +253,15 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
|
||||
private sealed class UpperRedactor : IAuditRedactor
|
||||
{
|
||||
/// <summary>Upper-cases the event details so tests can see the filtered copy.</summary>
|
||||
/// <param name="rawEvent">The audit event to apply.</param>
|
||||
/// <returns>The event with upper-cased details.</returns>
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() };
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced SetAttribute whose write fails emits a failed row and then throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows()
|
||||
{
|
||||
@@ -261,6 +279,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal("device rejected", row.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced wait emits an attribute-wait row with a satisfied outcome.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitInternal_WhenTraced_EmitsAttributeWait_WithOutcomeSatisfied()
|
||||
{
|
||||
@@ -287,6 +307,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Single(collector.Snapshot());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced wait that times out emits a failed row with a timeout outcome.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitInternal_WhenTraced_Timeout_EmitsFailedRow_WithTimeoutOutcome()
|
||||
{
|
||||
@@ -305,6 +327,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal("timeout", ScriptTraceExtra.TryParse(row.Extra)!.WaitOutcome);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced wait emits nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitInternal_WhenNotTraced_EmitsNothing()
|
||||
{
|
||||
@@ -320,6 +344,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Empty(writer.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced CallScript propagates the trace flag on the script call request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_WhenTraced_PropagatesTraceOnScriptCallRequest()
|
||||
{
|
||||
@@ -337,6 +363,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.False(CreateContext(probe.Ref, null, traced: false, null).CreateChildContextForSharedScript(1).IsTraced);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced CallScript sends a request with the trace flag false.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CallScript_WhenNotTraced_RequestTraceIsFalse()
|
||||
{
|
||||
@@ -350,8 +378,10 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
await call;
|
||||
}
|
||||
|
||||
// ---- Outbound rows ride the collector (plan 2026-09-06 Task 3) -------------------------
|
||||
// ---- Outbound rows ride the collector -------------------------
|
||||
|
||||
/// <summary>Verifies that a traced external system call collects the same API-call row that the writer receives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExternalSystemCall_WhenTraced_CollectsTheSameApiCallRow_TheWriterReceives()
|
||||
{
|
||||
@@ -372,6 +402,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(exec, Row(collected).ExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered cached external call collects only the cached-submit row, as the same instance the telemetry receives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExternalSystemCachedCall_Buffered_CollectsCachedSubmitOnly_SameInstanceAsTelemetry()
|
||||
{
|
||||
@@ -390,6 +422,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(trackedId.Value, Row(collected).CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an immediately completing cached external call collects the submit, attempted and resolve rows while the run is open.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExternalSystemCachedCall_ImmediateCompletion_CollectsSubmitAttemptedResolve_WhileRunIsOpen()
|
||||
{
|
||||
@@ -410,6 +444,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
snap.Select(e => Row(e).Kind));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that outbound rows emitted after the collector completed do not touch the collector but still reach telemetry.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OutboundRows_AfterCollectorCompleted_DoNotTouchTheCollector_ButStillReachTelemetry()
|
||||
{
|
||||
@@ -439,6 +475,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(snapshot.Select(e => e.EventId), collector.Snapshot().Select(e => e.EventId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced database execute collects the same database-write row that the writer receives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DatabaseExecute_WhenTraced_CollectsTheSameDbWriteRow_TheWriterReceives()
|
||||
{
|
||||
@@ -464,6 +502,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Contains("\"rowsAffected\":1", Row(collected).Extra);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a buffered cached database write collects the cached-submit row, as the same instance the telemetry receives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DatabaseCachedWrite_Buffered_CollectsCachedSubmit_SameInstanceAsTelemetry()
|
||||
{
|
||||
@@ -490,6 +530,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(trackedId.Value, Row(collected).CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced Notify.Send collects the same notification row that the writer receives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NotifySend_WhenTraced_CollectsTheSameNotifySendRow_TheWriterReceives()
|
||||
{
|
||||
@@ -510,6 +552,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(Guid.Parse(notificationId), Row(collected).CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that without a collector the writer and telemetry still receive every outbound row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OutboundRows_WhenNoCollector_WriterAndTelemetryStillReceiveEveryRow()
|
||||
{
|
||||
@@ -543,6 +587,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(AuditKind.CachedSubmit, Row(Assert.Single(forwarder.Telemetry).Audit).Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing trace sink never fails the call and the writer still gets the row.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ThrowingTraceSink_NeverFailsTheCall_AndTheWriterStillGetsTheRow()
|
||||
{
|
||||
@@ -565,10 +611,12 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
/// the runtime line test (c) needs a genuine <c>_logger.LogWarning</c> inside a step body.</summary>
|
||||
private sealed class ThrowingAuditWriter : IAuditWriter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("writer down");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced Log call emits one script-log row to both the collector and the writer.</summary>
|
||||
[Fact]
|
||||
public void Log_WhenTraced_EmitsOneScriptLogRow_ToTheCollectorAndTheWriter()
|
||||
{
|
||||
@@ -598,6 +646,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(1, extra.Seq);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced Log call reaches neither the writer nor the collector.</summary>
|
||||
[Fact]
|
||||
public void Log_WhenNotTraced_ReachesNeitherTheWriterNorTheCollector()
|
||||
{
|
||||
@@ -614,6 +663,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.False(collector.LogsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trace write failure does not create diagnostic trace rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TraceWriteFailure_DoesNotCreateDiagnosticTraceRows()
|
||||
{
|
||||
@@ -628,6 +679,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal(AuditKind.AttributeWrite, Row(collector.Snapshot().Single()).Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the context caches its helpers per context.</summary>
|
||||
[Fact]
|
||||
public void Helpers_AreCachedPerContext()
|
||||
{
|
||||
@@ -642,6 +694,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.NotSame(ctx.Scripts, child.Scripts);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Log call with an exception carries the message and the full exception detail.</summary>
|
||||
[Fact]
|
||||
public void Log_WithException_CarriesTheMessageAndTheFullDetail()
|
||||
{
|
||||
@@ -657,6 +710,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Equal("warn", ScriptTraceExtra.TryParse(row.Extra)!.LogLevel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that log rows use the low-priority lane so that step rows keep their reserve.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LogRows_RideTheLowPriorityLane_SoStepRowsKeepTheirReserve()
|
||||
{
|
||||
@@ -682,6 +737,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
Assert.Contains(collector.Snapshot(), e => Row(e).Kind == AuditKind.AttributeWrite);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that FlushDropped emits the closing row once the per-run cap is exceeded.</summary>
|
||||
[Fact]
|
||||
public void FlushDropped_EmitsTheClosingRow_OnceThePerRunCapIsExceeded()
|
||||
{
|
||||
@@ -709,6 +765,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
/// that node. Provoked the same way as the write case — a writer that throws makes the
|
||||
/// helper's own best-effort "audit write threw" warning fire inside the scope.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RuntimeWarning_InsideATracedExternalCall_IsAttributedToTheApiCallStep()
|
||||
{
|
||||
@@ -732,6 +789,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
/// inline collector — a traced run without one still writes durable <c>ScriptLog</c> rows that
|
||||
/// have to name their step.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TracedExternalCall_WithoutACollector_StillNamesTheApiCallStep()
|
||||
{
|
||||
@@ -786,6 +844,7 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
/// The batch and wait scopes unwind even when trace writes fail; their internal
|
||||
/// diagnostics stay out of the collector, and the later script line belongs to the run.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NestedStepScopes_IgnoreTraceDiagnostics_AndUnwind()
|
||||
{
|
||||
@@ -820,10 +879,11 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36: the reset-aware handshake is four traced steps in leg order — reset write,
|
||||
/// The reset-aware handshake is four traced steps in leg order — reset write,
|
||||
/// reset-echo wait, batch write, response wait — each with its own row id, with no new
|
||||
/// audit kinds (the Flow Debugger projection needs no change).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteBatchResetAndWait_WhenTraced_EmitsFourStepRowsInLegOrder()
|
||||
{
|
||||
|
||||
+2
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// </summary>
|
||||
public class ScriptSchedulerStatsReporterTests
|
||||
{
|
||||
/// <summary>Verifies that the reporter pushes the scheduler statistics to the health collector.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Reporter_PushesSchedulerStatsToCollector()
|
||||
{
|
||||
|
||||
@@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Shared Script Library tests — compile, register, execute inline.
|
||||
/// Shared Script Library tests — compile, register, execute inline.
|
||||
/// </summary>
|
||||
public class SharedScriptLibraryTests
|
||||
{
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _library;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>SharedScriptLibraryTests</c> class.</summary>
|
||||
public SharedScriptLibraryTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
@@ -19,6 +20,7 @@ public class SharedScriptLibraryTests
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compile and register valid script succeeds.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_ValidScript_Succeeds()
|
||||
{
|
||||
@@ -27,6 +29,7 @@ public class SharedScriptLibraryTests
|
||||
Assert.True(_library.Contains("add"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compile and register invalid script returns false.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_InvalidScript_ReturnsFalse()
|
||||
{
|
||||
@@ -35,6 +38,7 @@ public class SharedScriptLibraryTests
|
||||
Assert.False(_library.Contains("bad"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compile and register forbidden api returns false.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_ForbiddenApi_ReturnsFalse()
|
||||
{
|
||||
@@ -42,6 +46,7 @@ public class SharedScriptLibraryTests
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compile and register replaces existing script.</summary>
|
||||
[Fact]
|
||||
public void CompileAndRegister_Replaces_ExistingScript()
|
||||
{
|
||||
@@ -53,6 +58,7 @@ public class SharedScriptLibraryTests
|
||||
Assert.Equal(1, _library.GetRegisteredScriptNames().Count(n => n == "calc"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that remove registered script returns true.</summary>
|
||||
[Fact]
|
||||
public void Remove_RegisteredScript_ReturnsTrue()
|
||||
{
|
||||
@@ -61,12 +67,14 @@ public class SharedScriptLibraryTests
|
||||
Assert.False(_library.Contains("temp"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that remove nonexistent script returns false.</summary>
|
||||
[Fact]
|
||||
public void Remove_NonexistentScript_ReturnsFalse()
|
||||
{
|
||||
Assert.False(_library.Remove("nonexistent"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get registered script names returns all names.</summary>
|
||||
[Fact]
|
||||
public void GetRegisteredScriptNames_ReturnsAllNames()
|
||||
{
|
||||
@@ -81,6 +89,8 @@ public class SharedScriptLibraryTests
|
||||
Assert.Contains("c", names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute async nonexistent script throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NonexistentScript_Throws()
|
||||
{
|
||||
|
||||
+8
-4
@@ -37,6 +37,7 @@ public class SiteScriptCompileCacheTests
|
||||
private static ScriptCompilationResult Ok() =>
|
||||
ScriptCompilationResult.Succeeded(CSharpScript.Create<object?>("1", ScriptOptions.Default, typeof(ScriptGlobals)));
|
||||
|
||||
/// <summary>Verifies that GetOrAdd with the same code and globals computes the value only once.</summary>
|
||||
[Fact]
|
||||
public void GetOrAdd_SameCodeAndGlobals_ComputesOnce()
|
||||
{
|
||||
@@ -52,6 +53,7 @@ public class SiteScriptCompileCacheTests
|
||||
Assert.Equal(1, _cache.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetOrAdd with the same code but different globals creates separate entries.</summary>
|
||||
[Fact]
|
||||
public void GetOrAdd_SameCode_DifferentGlobals_AreSeparateEntries()
|
||||
{
|
||||
@@ -114,6 +116,7 @@ public class SiteScriptCompileCacheTests
|
||||
() => throw new InvalidOperationException("our entry was evicted by another cache")));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that overflowing the cache keeps it within its bound.</summary>
|
||||
[Fact]
|
||||
public void Overflow_StaysWithinTheBound()
|
||||
{
|
||||
@@ -132,10 +135,10 @@ public class SiteScriptCompileCacheTests
|
||||
Assert.True(recomputed > 0, "no oldest-batch entry was evicted — the overflow sweep did not run");
|
||||
}
|
||||
|
||||
// ── WP3.1: approximate LRU replaced the wholesale-Clear overflow cliff ──────────
|
||||
// ── Approximate LRU replaced the wholesale-Clear overflow cliff ──────────
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 (test group 8): overflow must evict only the OLDEST batch, keeping hot entries.
|
||||
/// Overflow must evict only the OLDEST batch, keeping hot entries.
|
||||
/// The old behaviour cleared all 1024 entries, so the very next deploy or Instance-Actor
|
||||
/// start paid a full recompile storm on actor threads.
|
||||
/// </summary>
|
||||
@@ -177,10 +180,11 @@ public class SiteScriptCompileCacheTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1: concurrent inserts crossing the bound together must not blow past it — the
|
||||
/// Concurrent inserts crossing the bound together must not blow past it — the
|
||||
/// eviction sweep is double-checked under its own lock precisely so a stampede performs
|
||||
/// one sweep rather than one per racing thread.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentGetOrAddStorm_KeepsTheBound()
|
||||
{
|
||||
@@ -199,7 +203,7 @@ public class SiteScriptCompileCacheTests
|
||||
// (register row 100 — it used to be 512, to absorb inserts from other test classes
|
||||
// sharing the process-global cache; nothing foreign can reach this instance).
|
||||
// The property under test is that 3200 distinct inserts do not accumulate — before
|
||||
// WP3.1 this was a wholesale clear, and a per-entry eviction bug would show up here as
|
||||
// the change to approximate LRU this was a wholesale clear, and a per-entry eviction bug would show up here as
|
||||
// unbounded growth, not as a handful of extra rows.
|
||||
Assert.True(_cache.Count <= SiteScriptCompileCache.MaxEntries + tasks,
|
||||
$"cache grew past its bound: {_cache.Count} vs max {SiteScriptCompileCache.MaxEntries} " +
|
||||
|
||||
@@ -18,17 +18,33 @@ public class TraceCapturingLoggerTests
|
||||
/// <summary>An inner logger that records what it was asked to write, at a configurable minimum level.</summary>
|
||||
private sealed class RecordingLogger(LogLevel minimum = LogLevel.Trace) : ILogger
|
||||
{
|
||||
/// <summary>Gets the entries written to this logger.</summary>
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
/// <summary>Gets the scopes begun on this logger.</summary>
|
||||
public List<object?> Scopes { get; } = [];
|
||||
|
||||
/// <summary>Records the scope state and returns no scope handle.</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state.</param>
|
||||
/// <returns>Always <see langword="null"/>.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
|
||||
{
|
||||
Scopes.Add(state);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Reports whether the level meets the configured minimum.</summary>
|
||||
/// <param name="logLevel">The log level being checked.</param>
|
||||
/// <returns><see langword="true"/> when the level is at or above the minimum.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= minimum;
|
||||
|
||||
/// <summary>Records the formatted message for later assertions.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="level">The log level.</param>
|
||||
/// <param name="eventId">The event id.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The exception being logged, if any.</param>
|
||||
/// <param name="formatter">The message formatter.</param>
|
||||
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add((level, formatter(state, exception)));
|
||||
}
|
||||
@@ -47,6 +63,7 @@ public class TraceCapturingLoggerTests
|
||||
return (new TraceCapturingLogger(inner, sink), inner, rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a debug entry is forwarded to the inner logger but not captured.</summary>
|
||||
[Fact]
|
||||
public void Debug_IsForwarded_ButNotCaptured()
|
||||
{
|
||||
@@ -58,6 +75,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a warning is captured as a runtime row together with its exception.</summary>
|
||||
[Fact]
|
||||
public void Warning_IsCapturedAsARuntimeRow_WithItsException()
|
||||
{
|
||||
@@ -74,6 +92,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Same(boom, row.Exception);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that error and critical entries are both captured as errors.</summary>
|
||||
[Fact]
|
||||
public void ErrorAndCritical_AreBothCapturedAsError()
|
||||
{
|
||||
@@ -85,6 +104,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Equal(["error", "error"], rows.Select(r => r.Level));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that information level is enabled even when the inner logger disables it.</summary>
|
||||
[Fact]
|
||||
public void InformationIsEnabled_EvenWhenTheInnerLoggerSaysNo()
|
||||
{
|
||||
@@ -102,6 +122,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Equal("captured anyway", row.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that below information level the inner logger decides whether the level is enabled.</summary>
|
||||
[Fact]
|
||||
public void BelowInformation_TheInnerLoggerDecidesEnablement()
|
||||
{
|
||||
@@ -110,6 +131,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.False(logger.IsEnabled(LogLevel.Debug));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a disabled inner logger is not written to but the row is still captured.</summary>
|
||||
[Fact]
|
||||
public void ADisabledInnerLogger_IsNotWrittenTo_ButTheRowIsStillCaptured()
|
||||
{
|
||||
@@ -121,6 +143,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Single(rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that captured lines carry the ambient step id.</summary>
|
||||
[Fact]
|
||||
public void CapturedLines_CarryTheAmbientStepId()
|
||||
{
|
||||
@@ -135,6 +158,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Equal(stepId, Assert.Single(rows).StepEventId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the constructor rejects a logger that is already decorated.</summary>
|
||||
[Fact]
|
||||
public void Ctor_RejectsAnAlreadyDecoratedLogger()
|
||||
{
|
||||
@@ -147,6 +171,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Equal("inner", ex.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the constructor requires both collaborators.</summary>
|
||||
[Fact]
|
||||
public void Ctor_RequiresBothCollaborators()
|
||||
{
|
||||
@@ -156,10 +181,11 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(new RecordingLogger(), null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing formatter is swallowed and drops only that line.</summary>
|
||||
[Fact]
|
||||
public void AThrowingFormatter_IsSwallowed_AndDropsOnlyThatLine()
|
||||
{
|
||||
// Review M3. Most of what this decorator captures is the runtime's own best-effort
|
||||
// Most of what this decorator captures is the runtime's own best-effort
|
||||
// "audit write failed" warnings, logged from INSIDE a catch block: a formatter exception
|
||||
// escaping here would replace the original failure with a formatting one on a path whose
|
||||
// contract is that it never throws. The inner logger is disabled for this level so the
|
||||
@@ -177,6 +203,7 @@ public class TraceCapturingLoggerTests
|
||||
Assert.Equal("still alive", Assert.Single(rows).Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that beginning a scope delegates to the inner logger.</summary>
|
||||
[Fact]
|
||||
public void BeginScope_DelegatesToTheInnerLogger()
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M3 Bundle A — Task A3) — script-side API tests for
|
||||
/// Script-side API tests for
|
||||
/// <c>Tracking.Status(TrackedOperationId)</c>. The helper reads the site-local
|
||||
/// <see cref="IOperationTrackingStore"/> directly (no central round-trip) and
|
||||
/// returns the latest <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when
|
||||
@@ -21,6 +21,8 @@ public class TrackingApiTests
|
||||
return new ScriptRuntimeContext.TrackingHelper(store, NullLogger.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status for an unknown id returns null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Status_UnknownId_ReturnsNull()
|
||||
{
|
||||
@@ -35,6 +37,8 @@ public class TrackingApiTests
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status for a known id returns the latest snapshot.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Status_KnownId_ReturnsLatestSnapshot()
|
||||
{
|
||||
@@ -66,6 +70,8 @@ public class TrackingApiTests
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status throws when no tracking store is wired.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Status_NoStoreWired_Throws()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
public class TriggerEvalGateLifecycleTests
|
||||
{
|
||||
/// <summary>Verifies that the shared gate is process-owned and remains usable after a caller disposes it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SharedGate_IsProcessOwnedAndRemainsUsableAfterCallerDisposes()
|
||||
{
|
||||
@@ -15,6 +17,7 @@ public class TriggerEvalGateLifecycleTests
|
||||
gate.Release();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an owned gate is disposed by its caller.</summary>
|
||||
[Fact]
|
||||
public void OwnedGate_IsDisposedByCaller()
|
||||
{
|
||||
|
||||
@@ -4,12 +4,12 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-011: regression tests for the semantic-analysis trust-model validation.
|
||||
/// Regression tests for the semantic-analysis trust-model validation.
|
||||
/// The previous implementation was a raw substring scan of the source text — it both
|
||||
/// missed forbidden APIs (no literal namespace string) and raised false positives on
|
||||
/// the namespace string appearing in comments, string literals or unrelated identifiers.
|
||||
///
|
||||
/// As of M3.3, <c>ValidateTrustModel</c> delegates to the shared authoritative
|
||||
/// The <c>ValidateTrustModel</c> delegates to the shared authoritative
|
||||
/// <c>ScriptAnalysis.ScriptTrustValidator</c>, which retains this same Roslyn
|
||||
/// semantic-symbol analysis (plus reflection-gateway hardening), so these bypass /
|
||||
/// false-positive / allowed-exception regressions continue to hold through the
|
||||
@@ -22,6 +22,8 @@ public class TrustModelSemanticTests
|
||||
|
||||
// ── Bypass cases (under-inclusive substring scan would MISS these) ──
|
||||
|
||||
/// <summary>Verifies that a global-qualified forbidden type is detected by the trust model.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TrustModel_GlobalQualifiedForbiddenType_IsDetected()
|
||||
{
|
||||
@@ -32,6 +34,8 @@ public class TrustModelSemanticTests
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a forbidden type referenced through a using alias is detected.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TrustModel_ForbiddenTypeViaUsingAlias_IsDetected()
|
||||
{
|
||||
@@ -49,6 +53,8 @@ public class TrustModelSemanticTests
|
||||
|
||||
// ── False-positive cases (over-inclusive substring scan would WRONGLY flag these) ──
|
||||
|
||||
/// <summary>Verifies that a forbidden namespace inside a string literal is not flagged.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TrustModel_ForbiddenNamespaceInStringLiteral_IsNotFlagged()
|
||||
{
|
||||
@@ -58,6 +64,8 @@ public class TrustModelSemanticTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a forbidden namespace inside a comment is not flagged.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TrustModel_ForbiddenNamespaceInComment_IsNotFlagged()
|
||||
{
|
||||
@@ -70,6 +78,8 @@ public class TrustModelSemanticTests
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unrelated identifier containing a forbidden substring is not flagged.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
public void TrustModel_UnrelatedIdentifierContainingForbiddenSubstring_IsNotFlagged()
|
||||
{
|
||||
@@ -84,6 +94,8 @@ public class TrustModelSemanticTests
|
||||
|
||||
// ── Allowed exceptions still resolve correctly ──
|
||||
|
||||
/// <summary>Verifies that Task and CancellationToken remain allowed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public void TrustModel_TaskAndCancellationToken_RemainAllowed()
|
||||
{
|
||||
|
||||
+13
-2
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED
|
||||
/// Design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED
|
||||
/// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes
|
||||
/// the batch unless the reset echo was observed. The probe stands in for the InstanceActor
|
||||
/// so the order and the "nothing after a failed reset" property are directly observable.
|
||||
@@ -30,6 +30,8 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(30),
|
||||
instanceName: Instance, logger: NullLogger.Instance);
|
||||
|
||||
/// <summary>Verifies that the legs run in order and the result reports the response phase.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Legs_RunInOrder_AndReportTheResponsePhase()
|
||||
{
|
||||
@@ -78,6 +80,8 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
Assert.Null(result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reset echo timeout reports the reset phase and never writes the batch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch()
|
||||
{
|
||||
@@ -100,10 +104,12 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
Assert.True(result.ResetFailed);
|
||||
Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage);
|
||||
|
||||
// The property that fixes #36: no batch, no trigger, after a reset that was never echoed.
|
||||
// The property under test: no batch, no trigger, after a reset that was never echoed.
|
||||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a response timeout reports the response phase.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResponseTimeout_ReportsResponsePhase()
|
||||
{
|
||||
@@ -137,6 +143,7 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
/// A reset wait that fails WITHOUT timing out (a predicate blew up in the waiter) surfaces the
|
||||
/// actor's own message verbatim rather than a synthesized one, and still writes nothing further.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResetWaitError_PassesTheActorsMessageThrough()
|
||||
{
|
||||
@@ -166,6 +173,7 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
/// The batch leg keeps throwing after a successful reset — a device-rejected batch is a hard
|
||||
/// failure, not a <see cref="HandshakeResult"/> the script could mistake for a timeout.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BatchFailureAfterASuccessfulReset_StillThrows()
|
||||
{
|
||||
@@ -188,6 +196,8 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
Assert.Contains("Batch write failed", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reset write failure throws, just like a failure of the batch leg.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResetWriteFailure_Throws_LikeTheBatchLeg()
|
||||
{
|
||||
@@ -207,6 +217,7 @@ public class WriteBatchResetAndWaitTests : TestKit
|
||||
}
|
||||
|
||||
/// <summary>The bool overload is untouched: batch first, then the wait, no reset leg.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LegacyOverload_StillWritesTheBatchFirst()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user