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:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -14,8 +14,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP-16: Alarm Actor tests — value match, range violation, rate of change.
/// WP-21: Alarm on-trigger call direction tests.
/// Alarm Actor tests — value match, range violation, rate of change.
/// Alarm on-trigger call direction tests.
/// </summary>
public class AlarmActorTests : TestKit, IDisposable
{
@@ -23,6 +23,7 @@ public class AlarmActorTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly ScriptCompilationService _compilationService;
/// <summary>Initializes the test with a compilation service, shared script library and default runtime options.</summary>
public AlarmActorTests()
{
_compilationService = new ScriptCompilationService(
@@ -32,11 +33,13 @@ public class AlarmActorTests : TestKit, IDisposable
_options = new SiteRuntimeOptions();
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
}
/// <summary>Verifies that a value-match alarm activates when the value matches.</summary>
[Fact]
public void AlarmActor_ValueMatch_ActivatesOnMatch()
{
@@ -64,6 +67,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal("HighTemp", msg.AlarmName);
}
/// <summary>Verifies that a value-match alarm clears when the value no longer matches.</summary>
[Fact]
public void AlarmActor_ValueMatch_ClearsOnNonMatch()
{
@@ -94,6 +98,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Normal, clearMsg.State);
}
/// <summary>Verifies that a computed alarm carries the unified condition state.</summary>
[Fact]
public void AlarmActor_ComputedAlarm_CarriesUnifiedConditionState()
{
@@ -123,6 +128,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(string.Empty, msg.SourceReference); // no native source reference
}
/// <summary>Verifies that a range-violation alarm activates when the value is outside the range.</summary>
[Fact]
public void AlarmActor_RangeViolation_ActivatesOutsideRange()
{
@@ -152,6 +158,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, msg.State);
}
/// <summary>Verifies that a range-violation alarm clears when the value is back in range.</summary>
[Fact]
public void AlarmActor_RangeViolation_ClearsWhenBackInRange()
{
@@ -181,6 +188,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Normal, clearMsg.State);
}
/// <summary>Verifies that the alarm actor ignores changes to attributes it does not monitor.</summary>
[Fact]
public void AlarmActor_IgnoresUnmonitoredAttributes()
{
@@ -205,6 +213,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that an alarm that is already active does not trigger again.</summary>
[Fact]
public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive()
{
@@ -233,6 +242,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that the alarm actor starts in the normal state after a restart.</summary>
[Fact]
public void AlarmActor_StartsNormal_OnRestart()
{
@@ -257,10 +267,11 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that no on-clear script runs when the alarm deactivates.</summary>
[Fact]
public void AlarmActor_NoClearScript_OnDeactivation()
{
// WP-16: On clear, NO script is executed. Only on activate.
// On clear, NO script is executed. Only on activate.
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "ClearTest",
@@ -320,6 +331,7 @@ public class AlarmActorTests : TestKit, IDisposable
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
"Good", ts);
/// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid rise.</summary>
[Fact]
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
{
@@ -339,6 +351,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, msg.State);
}
/// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid fall.</summary>
[Fact]
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
{
@@ -356,6 +369,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, msg.State);
}
/// <summary>Verifies that an either-direction rate-of-change alarm does not activate when the rate is below the threshold.</summary>
[Fact]
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
{
@@ -370,6 +384,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a rising rate-of-change alarm ignores falling spikes.</summary>
[Fact]
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
{
@@ -384,6 +399,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a falling rate-of-change alarm ignores rising spikes.</summary>
[Fact]
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
{
@@ -397,6 +413,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a falling rate-of-change alarm activates on a falling rate.</summary>
[Fact]
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
{
@@ -411,6 +428,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, msg.State);
}
/// <summary>Verifies that a rate-of-change alarm does not activate on a single sample.</summary>
[Fact]
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
{
@@ -424,6 +442,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that rate-of-change samples that roll out of the window are discarded.</summary>
[Fact]
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
{
@@ -442,6 +461,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a rate-of-change alarm clears when the rate drops back.</summary>
[Fact]
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
{
@@ -465,6 +485,7 @@ public class AlarmActorTests : TestKit, IDisposable
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
/// <summary>Verifies that a value-match alarm using the legacy attribute and value keys still fires.</summary>
[Fact]
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
{
@@ -490,6 +511,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, msg.State);
}
/// <summary>Verifies that a value-match alarm with a not-equals prefix fires when the value differs.</summary>
[Fact]
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
{
@@ -521,6 +543,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Normal, clear.State);
}
/// <summary>Verifies that a range-violation alarm using the legacy low and high keys still fires.</summary>
[Fact]
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
{
@@ -574,6 +597,7 @@ public class AlarmActorTests : TestKit, IDisposable
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
"Good", DateTimeOffset.UtcNow);
/// <summary>Verifies that a HiLo alarm enters the High level when the value crosses the Hi setpoint.</summary>
[Fact]
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
{
@@ -591,6 +615,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.High, msg.Level);
}
/// <summary>Verifies that a HiLo alarm escalates to HighHigh when the value climbs past the HiHi setpoint.</summary>
[Fact]
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
{
@@ -609,6 +634,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.HighHigh, second.Level);
}
/// <summary>Verifies that a HiLo alarm descalates from HighHigh to High when the value drops.</summary>
[Fact]
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
{
@@ -626,6 +652,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.High, msg.Level);
}
/// <summary>Verifies that a HiLo alarm clears to Normal when the value returns to the normal band.</summary>
[Fact]
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
{
@@ -643,6 +670,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.None, clear.Level);
}
/// <summary>Verifies that a HiLo alarm enters Low when the value crosses the Lo setpoint.</summary>
[Fact]
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
{
@@ -656,6 +684,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.Low, msg.Level);
}
/// <summary>Verifies that a HiLo alarm enters LowLow when the value crosses the LoLo setpoint.</summary>
[Fact]
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
{
@@ -669,6 +698,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.LowLow, msg.Level);
}
/// <summary>Verifies that a per-setpoint priority overrides the alarm-level priority.</summary>
[Fact]
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
{
@@ -688,6 +718,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(900, hiHi.Priority);
}
/// <summary>Verifies that a setpoint without its own priority falls back to the alarm-level priority.</summary>
[Fact]
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
{
@@ -701,6 +732,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(432, msg.Priority);
}
/// <summary>Verifies that with only HiHi configured a HiLo alarm has no effect in the low range.</summary>
[Fact]
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
{
@@ -720,6 +752,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
}
/// <summary>Verifies that a value exactly at the HiHi setpoint resolves to HighHigh.</summary>
[Fact]
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
{
@@ -735,6 +768,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
}
/// <summary>Verifies that a HiLo alarm that stays at the same level emits no redundant state change.</summary>
[Fact]
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
{
@@ -752,6 +786,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>Verifies that a HiLo alarm with no setpoints configured never fires.</summary>
[Fact]
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
{
@@ -769,6 +804,7 @@ public class AlarmActorTests : TestKit, IDisposable
// ── HiLo hysteresis ────────────────────────────────────────────────────
/// <summary>Verifies that with hysteresis a HiLo alarm stays at HighHigh until the value drops below the deadband.</summary>
[Fact]
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
{
@@ -787,6 +823,7 @@ public class AlarmActorTests : TestKit, IDisposable
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Verifies that with hysteresis a HiLo alarm drops to High only after the deadband is cleared.</summary>
[Fact]
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
{
@@ -803,6 +840,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.High, msg.Level);
}
/// <summary>Verifies that the Hi deadband prevents a HiLo alarm from flapping.</summary>
[Fact]
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
{
@@ -822,6 +860,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.None, clear.Level);
}
/// <summary>Verifies that hysteresis on the low side behaves symmetrically to the high side.</summary>
[Fact]
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
{
@@ -842,6 +881,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.None, clear.Level);
}
/// <summary>Verifies that a per-band message flows through to the alarm state change.</summary>
[Fact]
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
{
@@ -863,6 +903,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(string.Empty, clear.Message);
}
/// <summary>Verifies that hysteresis does not delay escalation to a higher level.</summary>
[Fact]
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
{
@@ -880,8 +921,9 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
}
// ── M1.5: site event log `alarm` category ──────────────────────────────
// ── Site event log `alarm` category ──────────────────────────────
/// <summary>Verifies that raising an alarm emits an alarm site event.</summary>
[Fact]
public void AlarmActor_Raise_EmitsAlarmSiteEvent()
{
@@ -918,6 +960,7 @@ public class AlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that raising a low-priority alarm emits a warning-severity alarm site event.</summary>
[Fact]
public void AlarmActor_RaiseLowPriority_EmitsWarningAlarmSiteEvent()
{
@@ -950,6 +993,7 @@ public class AlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that clearing an alarm emits an info-severity alarm site event.</summary>
[Fact]
public void AlarmActor_Clear_EmitsInfoAlarmSiteEvent()
{
@@ -986,6 +1030,7 @@ public class AlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that a malformed trigger configuration does not crash the alarm actor.</summary>
[Fact]
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
{
@@ -1027,13 +1072,14 @@ public class AlarmActorTests : TestKit, IDisposable
return s;
}
/// <summary>Verifies that an expression alarm evaluates off the blocking script pool and activates.</summary>
[Fact]
public void ExpressionAlarm_EvaluatesOffTheBlockingScriptPool_AndActivates()
{
// WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
// Retarget of the former assertion, whose sense is deliberately INVERTED.
//
// P1 moved the evaluation off the actor's dispatcher and onto the dedicated
// script-execution scheduler, and this test asserted exactly that. WP3.1 (finding #4)
// An earlier change moved the evaluation off the actor's dispatcher and onto the dedicated
// script-execution scheduler, and this test asserted exactly that. Later analysis
// proved that destination wrong for alarms in particular: sharing the blocking pool
// meant an alarm that should raise in milliseconds queued behind blocked script bodies
// for an unbounded time, and its 2 s evaluation timeout did not even start ticking
@@ -1063,6 +1109,7 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, change.State);
}
/// <summary>Verifies that a fault in the expression evaluation task does not kill the alarm trigger.</summary>
[Fact]
public void ExpressionEvalTaskFault_DoesNotKillTheAlarmTrigger()
{
@@ -17,7 +17,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> tag-cascade, alarm leg):
/// <c>ParentExecutionId</c> tag-cascade, alarm leg:
/// an alarm on-trigger script run must chain under the execution whose attribute
/// write fired the alarm, and must stay a tree ROOT when the firing value had no
/// audited origin.
@@ -52,6 +52,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
private readonly TestLocalDb _localDb;
private readonly SiteStorageService _storage;
/// <summary>Initializes the test class with a compilation service, shared script library and a temporary LocalDb-backed site storage.</summary>
public AlarmCascadeParentExecutionTests()
{
_compilationService = new ScriptCompilationService(
@@ -64,6 +65,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
_storage.InitializeAsync().GetAwaiter().GetResult();
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -127,6 +129,8 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
// Hop 3 — Alarm Actor → AlarmExecutionActor
// -------------------------------------------------------------------------
/// <summary>Verifies that a script-written value firing an alarm parents the on-trigger run only for observed (not inferred) writes.</summary>
/// <param name="inferred">Whether the writer was inferred rather than observed.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -143,6 +147,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
Assert.Equal(inferred ? (Guid?)null : writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
}
/// <summary>Verifies that a value originating from the data connection layer leaves the on-trigger run as a tree root.</summary>
[Fact]
public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot()
{
@@ -159,6 +164,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
}
/// <summary>Verifies that a hi/lo alarm entering a band parents its on-trigger run to the writing execution.</summary>
[Fact]
public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution()
{
@@ -181,6 +187,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
}
/// <summary>Verifies that an expression alarm carries the writer captured with the evaluated snapshot.</summary>
[Fact]
public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot()
{
@@ -212,6 +219,8 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
// Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution
// -------------------------------------------------------------------------
/// <summary>Verifies that <c>SetAttribute</c> stamps the running execution id on the write command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand()
{
@@ -241,6 +250,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
// Hop 2 — Instance Actor carries the writer onto the published change
// -------------------------------------------------------------------------
/// <summary>Verifies that a static write publishes the writing execution id on the resulting change.</summary>
[Fact]
public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange()
{
@@ -281,6 +291,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
Assert.Equal(writerExecutionId, published.SourceExecutionId);
}
/// <summary>Verifies that a write with no audited origin publishes no execution id.</summary>
[Fact]
public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Final review M6: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a
/// Regression: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a
/// <c>sourceScript</c>, so the context's <c>_logTarget</c> — which names the script on every
/// <c>Log.*</c> line and on the run's trace rows — fell back to the INSTANCE name. The launcher now
/// passes the same <c>AlarmActor:{alarmName}</c> source key it already uses for this alarm's site
@@ -26,12 +26,27 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
/// <summary>A minimal recording logger — the alarm path hands its own ILogger down to the script logger.</summary>
private sealed class RecordingLogger<T> : ILogger<T>
{
/// <summary>Gets the log entries recorded so far.</summary>
public List<Entry> Entries { get; } = [];
/// <summary>Returns no scope.</summary>
/// <typeparam name="TState">The scope state type.</typeparam>
/// <param name="state">The scope state.</param>
/// <returns>Always null.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The log state type.</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 associated exception, if any.</param>
/// <param name="formatter">Formats the state and exception into a message.</param>
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add(new Entry(level, formatter(state, exception)));
@@ -42,8 +57,10 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance);
/// <inheritdoc />
void IDisposable.Dispose() => Shutdown();
/// <summary>Verifies that an alarm on-trigger run attributes its log lines to the alarm, not just the instance.</summary>
[Fact]
public void AlarmOnTriggerRun_AttributesItsLogLinesToTheALARM_NotJustTheInstance()
{
@@ -87,6 +104,7 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
},
TimeSpan.FromSeconds(15));
}
/// <summary>Verifies that an alarm run resolves and disposes its scoped integration services.</summary>
[Fact]
public void SR2_AlarmRunResolvesAndDisposesScopedIntegrationServices()
{
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// arch-review 2026-08-19 P1-3 — alarm on-trigger runs used to bypass the stuck-script
/// Alarm on-trigger runs used to bypass the stuck-script
/// watchdog, the script-error health counter, and <see cref="ScriptRunSummaryRecorder"/>
/// entirely: <c>ScriptRunLauncher.RunAlarmScriptAsync</c> carried none of the machinery its
/// sibling <c>RunScriptAsync</c> (the instance-script path) carries, on the SAME bounded
@@ -32,6 +32,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
/// <summary>Initializes the test class with a shared script library and a closed watchdog gate.</summary>
public AlarmScriptWatchdogTests()
{
var compilationService = new ScriptCompilationService(
@@ -41,6 +42,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
AlarmWatchdogHooks.Gate = new SemaphoreSlim(0);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Free any still-wedged worker before tearing down, so no test leaves a blocked thread.
@@ -99,6 +101,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
MaxScriptCallDepth = 10
};
/// <summary>Verifies that a wedged alarm script has its worker detached, a replacement started, and the queue drains once it is freed.</summary>
[Fact]
public void WedgedAlarmScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed()
{
@@ -142,6 +145,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
Assert.Equal(1, scheduler.MaximumConcurrencyLevel);
}
/// <summary>Verifies that an alarm run that timed out at dequeue increments the health collector's script error count.</summary>
[Fact]
public void AlarmRunTimedOutAtDequeue_IncrementsHealthCollectorScriptErrorCount()
{
@@ -196,6 +200,8 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25));
}
/// <summary>Verifies that an alarm run completion is recorded by the script run summary recorder.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmRunCompletion_IsRecordedByScriptRunSummaryRecorder()
{
@@ -21,6 +21,7 @@ public class CertStoreActorTests : TestKit, IDisposable
private readonly string _rejectedDir;
private readonly OpcUaGlobalOptions _options;
/// <summary>Initializes a new instance of the <see cref="CertStoreActorTests"/> class.</summary>
public CertStoreActorTests()
{
var root = Path.Combine(Path.GetTempPath(), "ScadaBridge-CertStoreTests", Guid.NewGuid().ToString("N"));
@@ -33,6 +34,7 @@ public class CertStoreActorTests : TestKit, IDisposable
};
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -69,6 +71,7 @@ public class CertStoreActorTests : TestKit, IDisposable
return (Convert.ToBase64String(der), cert.Thumbprint);
}
/// <summary>Verifies the case: write, then List, then Remove, round Trips.</summary>
[Fact]
public void Write_ThenList_ThenRemove_RoundTrips()
{
@@ -106,6 +109,7 @@ public class CertStoreActorTests : TestKit, IDisposable
emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint);
}
/// <summary>Verifies the case: export Local Trusted Certs, returns Thumbprint And Der Bytes.</summary>
[Fact]
public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes()
{
@@ -127,6 +131,7 @@ public class CertStoreActorTests : TestKit, IDisposable
entry.DerBytes.Should().Equal(expectedDer);
}
/// <summary>Verifies the case: write, invalid Base64, returns Failure Ack.</summary>
[Fact]
public void Write_InvalidBase64_ReturnsFailureAck()
{
@@ -138,6 +143,7 @@ public class CertStoreActorTests : TestKit, IDisposable
ack.Error.Should().NotBeNullOrEmpty();
}
/// <summary>Verifies the case: remove, missing Cert, is No Op Success.</summary>
[Fact]
public void Remove_MissingCert_IsNoOpSuccess()
{
@@ -148,6 +154,7 @@ public class CertStoreActorTests : TestKit, IDisposable
ack.Success.Should().BeTrue("removing an absent cert is an idempotent no-op");
}
/// <summary>Verifies the case: write, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary>
[Fact]
public void Write_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem()
{
@@ -167,6 +174,7 @@ public class CertStoreActorTests : TestKit, IDisposable
"the actor must not write outside the trusted-store directory");
}
/// <summary>Verifies the case: remove, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary>
[Fact]
public void Remove_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem()
{
@@ -67,6 +67,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
private readonly ConcurrentQueue<ReconfigureConnectionCommand> _reconfigures = new();
private readonly ConcurrentQueue<RemoveConnectionCommand> _removes = new();
/// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary>
public DclConnectionArbitrationTests()
{
_localDb = TestLocalDb.CreateTemp("dcl-arbitration-test");
@@ -78,6 +79,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
@@ -190,7 +192,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
// ── Rule 1: a definition row outranks an older flattened snapshot ──
/// <summary>
/// <b>THE row-89 reproduction, in its real (failover / restart) shape.</b> The store holds a
/// <b>THE reproduction, in its real (failover / restart) shape.</b> The store holds a
/// definition row pointing at the new endpoint — put there by a connection-only artifact
/// deploy and, on a replicated pair, delivered to this node by LocalDb CDC — while the
/// instance's own flattened config still carries the endpoint frozen in at ITS deploy time.
@@ -204,6 +206,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// that applied it.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DefinitionRow_WinsOverAnOlderFlattenedSnapshot_WhenTheSingletonBoots()
{
@@ -233,6 +236,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// guarding the flip after the fact.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisagreeingSources_DoNotPingPongTheConnection_AcrossManySweeps()
{
@@ -265,6 +269,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
///
/// <para>Passes before and after: a deploy-only regression guard.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_AtBoot()
{
@@ -287,6 +292,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
///
/// <para>Passes before and after: a deploy-only regression guard for the sweep path.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_OnTheSweepPath()
{
@@ -322,6 +328,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// manager's create-only guard and change nothing.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DefinitionArrivingAfterBoot_IsAdoptedByTheNextSweepTick()
{
@@ -352,7 +359,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
// ── Row 97: a changed configuration reaches the LIVE connection ──
/// <summary>
/// <b>THE row-97 reproduction on the sender side.</b> A connection the DCL already holds gets
/// <b>THE reproduction on the sender side.</b> A connection the DCL already holds gets
/// a new endpoint. What must go out is a <see cref="ReconfigureConnectionCommand"/>, which the
/// live connection actor applies in place by swapping its adapter and re-issuing its
/// subscriptions.
@@ -366,6 +373,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// failover, or a remove-then-add.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ChangedConfigurationForALiveConnection_IsSentAsAReconfigure_NotASecondCreate()
{
@@ -397,6 +405,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// a reconfigure — which drops the link and re-subscribes every tag — is safe to send on the
/// change edge.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnchangedConfiguration_SendsNeitherACreateNorAReconfigure()
{
@@ -423,7 +432,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
// ── Row 90: removal, and not being resurrected by a flattened snapshot ──
/// <summary>
/// <b>THE row-90 reproduction.</b> A definition row this incarnation had adopted disappears
/// <b>THE reproduction.</b> A definition row this incarnation had adopted disappears
/// from the store — a central delete, reconciled by an artifact deploy on the other node and
/// replicated here as a row deletion. The sweep can PROVE that removal (the store owned the
/// name a tick ago and does not now), so it stops the live connection actor; and the name is
@@ -435,6 +444,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// connection actor stayed live until the node restarted.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DefinitionRowDisappearing_StopsTheConnection_AndItIsNotResurrected()
{
@@ -483,6 +493,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// tick.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ArtifactReconcileRemoval_IsNotResurrectedByALaterInstanceStart()
{
@@ -533,6 +544,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// stay bad quality until the referencing instance is redeployed.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReAddedDefinition_ClearsTheTombstone_SoTheNextInstanceStartRecreatesIt()
{
@@ -581,6 +593,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// without the generation guard the actor removes the connection it created a moment ago.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleSweepSnapshot_RacingAnArtifactApply_DoesNotRemoveJustAppliedConnections()
{
@@ -603,10 +616,11 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
}
/// <summary>
/// The row-101 guard must not blunt row 90's legitimate disappearance detection: the same
/// The stale-snapshot guard must not blunt the legitimate disappearance detection: the same
/// empty snapshot stamped with the CURRENT write generation is a real answer (the store
/// genuinely holds no rows any more) and still stops the connection.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FreshEmptySweepSnapshot_StillRemovesDisappearedConnections()
{
@@ -640,6 +654,10 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
/// </summary>
private sealed class DclConnectionSpy : ReceiveActor
{
/// <summary>Creates the spy that records the connection commands it receives.</summary>
/// <param name="creates">Queue that receives create-connection commands.</param>
/// <param name="reconfigures">Queue that receives reconfigure-connection commands.</param>
/// <param name="removes">Queue that receives remove-connection commands.</param>
public DclConnectionSpy(
ConcurrentQueue<CreateConnectionCommand> creates,
ConcurrentQueue<ReconfigureConnectionCommand> reconfigures,
@@ -15,7 +15,7 @@ using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Task 10, site-secrets artifact deploy: <see cref="DeploymentManagerActor.HandleDeployArtifacts"/>
/// Site-secrets artifact deploy: <see cref="DeploymentManagerActor.HandleDeployArtifacts"/>
/// writes shipped secrets to <c>deployed_secrets</c>, runs <see cref="DeployedSecretsMirror.ReconcileAsync"/>
/// synchronously, and answers with <see cref="ArtifactDeploymentResponse.SecretsApplied"/> or a failure.
/// Follows the shape of the existing artifact-apply tests in <c>DeploymentManagerActorTests</c>.
@@ -27,6 +27,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of <c>DeploymentManagerActorArtifactSecretsTests</c>.</summary>
public DeploymentManagerActorArtifactSecretsTests()
{
_localDb = TestLocalDb.CreateTemp("dm-artifact-secrets-test");
@@ -40,6 +41,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -120,6 +122,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
UpdatedUtc: new DateTimeOffset(2026, 9, 28, 1, 0, 0, TimeSpan.Zero),
Description: "test secret");
/// <summary>Verifies that secrets null leaves table untouched and secrets applied null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_Null_LeavesTableUntouched_AndSecretsAppliedNull()
{
@@ -138,6 +142,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Empty(rows);
}
/// <summary>Verifies that secrets present full set write sync reconcile secrets applied equals shipped count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_Present_FullSetWrite_SyncReconcile_SecretsAppliedEqualsShippedCount()
{
@@ -158,6 +164,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.True(store.Contains("b"));
}
/// <summary>Verifies that secrets empty with mirror secrets applied zero success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_Empty_WithMirror_SecretsAppliedZero_Success()
{
@@ -173,6 +181,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Equal(0, response.SecretsApplied);
}
/// <summary>Verifies that secrets dropped reply names the retired secrets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_Dropped_ReplyNamesTheRetiredSecrets()
{
@@ -202,6 +212,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
await Task.CompletedTask;
}
/// <summary>Verifies that secrets mirror fails one success false kek id in message secrets applied excludes failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_MirrorFailsOne_SuccessFalse_KekIdInMessage_SecretsAppliedExcludesFailure()
{
@@ -222,6 +234,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Equal(1, response.SecretsApplied); // "good" applied+verified; "bad" failed
}
/// <summary>Verifies that secrets non empty no mirror registered fails deploy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_NonEmpty_NoMirrorRegistered_FailsDeploy()
{
@@ -234,6 +248,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Equal("Secret deployment is not enabled on this site node", response.ErrorMessage);
}
/// <summary>Verifies that secrets empty no mirror registered treated as zero success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_Empty_NoMirrorRegistered_TreatedAsZeroSuccess()
{
@@ -246,6 +262,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Equal(0, response.SecretsApplied);
}
/// <summary>Verifies that secrets removal failure does not fail deploy and excluded from error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Secrets_RemovalFailure_DoesNotFailDeploy_AndExcludedFromErrorMessage()
{
@@ -275,10 +293,11 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Null(r2.ErrorMessage);
}
/// <summary>Verifies that data connections pushed to dcl only after shipped secrets are mirrored.</summary>
[Fact]
public void DataConnections_PushedToDcl_OnlyAfterShippedSecretsAreMirrored()
{
// M2 (final review): a connection whose config references a secret shipped by THIS
// A connection whose config references a secret shipped by THIS
// deploy must find it on its first connect, so the DCL create must not reach the
// DCL manager before the secret is in this node's store.
var store = new FakeSecretStore();
@@ -310,6 +329,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.True(secretPresentWhenDclSawCreate);
}
/// <summary>Verifies that data connections still stored and pushed when secrets step fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DataConnections_StillStoredAndPushed_WhenSecretsStepFails()
{
@@ -336,19 +357,36 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
{
private readonly Dictionary<string, StoredSecret> _rows = new(StringComparer.Ordinal);
/// <summary>When set, deleting any secret throws.</summary>
public bool ThrowOnDelete { get; set; }
/// <summary>Returns whether the store currently holds a live secret with the given name.</summary>
/// <param name="name">The secret name.</param>
/// <returns>True when a live secret with that name exists.</returns>
public bool Contains(string name) => _rows.TryGetValue(name, out var row) && !row.IsDeleted;
/// <summary>Returns the stored or configured secret.</summary>
/// <param name="name">The secret name.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The secret, or null when absent.</returns>
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult(_rows.TryGetValue(name.Value, out var row) ? row : null);
/// <summary>Stores the row, replacing any existing row of the same name.</summary>
/// <param name="row">The secret row.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
{
_rows[row.Name.Value] = row;
return Task.CompletedTask;
}
/// <summary>Soft-deletes the named secret.</summary>
/// <param name="name">The secret name.</param>
/// <param name="actor">The acting user.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>True when a live secret was deleted; otherwise false.</returns>
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
{
if (ThrowOnDelete)
@@ -372,12 +410,23 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
return Task.FromResult(true);
}
/// <summary>Returns an empty metadata list.</summary>
/// <param name="includeDeleted">Whether deleted secrets are included.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An empty list.</returns>
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretMetadata>>([]);
/// <summary>Returns an empty manifest.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>An empty manifest.</returns>
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
/// <summary>Applies a replicated row when it is newer than the stored one.</summary>
/// <param name="row">The replicated row.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
{
if (_rows.TryGetValue(row.Name.Value, out var existing)
@@ -390,6 +439,11 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
return Task.CompletedTask;
}
/// <summary>Reports that no rewrap was applied.</summary>
/// <param name="rewrappedRow">The rewrapped row.</param>
/// <param name="expectedCurrentWrappedDek">The wrapped key the row is expected to hold.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Always false.</returns>
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
Task.FromResult(false);
@@ -399,8 +453,14 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
{
private readonly HashSet<string> _throwing = new(StringComparer.Ordinal);
/// <summary>Throws the configured fault when the named secret is requested.</summary>
/// <param name="name">The secret name that should fail.</param>
public void ThrowFor(string name) => _throwing.Add(name);
/// <summary>Returns the stored or configured secret.</summary>
/// <param name="name">The secret name.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The secret, or null when absent.</returns>
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
if (_throwing.Contains(name.Value))
@@ -32,6 +32,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test with a temporary SQLite storage and an in-memory shared script library.</summary>
public DeploymentManagerActorTests()
{
_localDb = TestLocalDb.CreateTemp("dm-test");
@@ -45,6 +46,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -94,7 +96,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
/// Builds a config carrying a single STATIC List attribute whose canonical
/// JSON-array default the Instance Actor decodes into a typed <c>List&lt;int&gt;</c>
/// in memory (InstanceActor.PreStart / DecodeAttributeValue). Used to drive a
/// routed wait whose matched value is a collection — the shape WS-4 normalizes
/// routed wait whose matched value is a collection — the shape that is normalized
/// for cross-process transport.
/// </summary>
private static string MakeConfigWithListAttributeJson(string instanceName)
@@ -118,7 +120,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
/// Builds a config carrying BOTH a static List attribute (whose JSON-array default
/// the Instance Actor decodes into a typed <c>List&lt;int&gt;</c>) and a static scalar
/// attribute. Used to drive a routed <see cref="RouteToGetAttributesRequest"/> that
/// exercises #162: the List value must be normalized for cross-process transport,
/// exercises list normalization: the List value must be normalized for cross-process transport,
/// while the scalar must pass through the normalizer unchanged.
/// </summary>
private static string MakeConfigWithListAndScalarAttributesJson(string instanceName)
@@ -161,6 +163,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
/// <summary>Verifies that the deployment manager creates instance actors from the stored configurations.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
{
@@ -182,6 +186,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("Pump1", response.InstanceUniqueName);
}
/// <summary>Verifies that the deployment manager skips disabled instances on startup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_SkipsDisabledInstances_OnStartup()
{
@@ -200,6 +206,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.True(response.Success);
}
/// <summary>Verifies that instance actors are created in staggered batches.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_StaggeredBatchCreation()
{
@@ -226,6 +234,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
}
}
/// <summary>Verifies that a deploy creates a new instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_Deploy_CreatesNewInstance()
{
@@ -251,6 +261,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Scripts = [new ResolvedScript { CanonicalName = "S1", Code = "this is not C# ~~~" }]
});
/// <summary>Verifies that a deploy with a non-compiling script reports failure and creates no instance actor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deploy_WithNonCompilingScript_ReportsFailed_AndCreatesNoInstanceActor()
{
@@ -277,6 +289,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
// ── S5: startup deployed-config load is retried on transient failure ──
/// <summary>Verifies that a startup load failure is retried until it succeeds.</summary>
[Fact]
public void StartupLoadFailure_IsRetried_UntilSuccess()
{
@@ -318,8 +331,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(10));
}
// ── M1.6: site event log `deployment` category ─────────────────────────
// ── Site event log `deployment` category ─────────────────────────
/// <summary>Verifies that a successful deploy emits a deployment site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_DeploySuccess_EmitsDeploymentSiteEvent()
{
@@ -345,6 +360,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that disable, enable and delete each emit a deployment site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_DisableEnableDelete_EmitDeploymentSiteEvents()
{
@@ -382,6 +399,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that an instance can be disabled, enabled and deleted through its lifecycle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
{
@@ -421,8 +440,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "LifecyclePump");
}
// ── DeploymentManager-006: query-the-site-before-redeploy ──
// ── Query-the-site-before-redeploy ──
/// <summary>Verifies that a state query for a deployed instance returns its applied identity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentStateQuery_DeployedInstance_ReturnsAppliedIdentity()
{
@@ -444,6 +465,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("sha256:applied", response.AppliedRevisionHash);
}
/// <summary>Verifies that a state query for an unknown instance reports it as not deployed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentStateQuery_UnknownInstance_ReturnsNotDeployed()
{
@@ -461,6 +484,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Null(response.AppliedRevisionHash);
}
/// <summary>Verifies that the supervision strategy resumes a child on an exception.</summary>
[Fact]
public void DeploymentManager_SupervisionStrategy_ResumesOnException()
{
@@ -475,8 +499,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal(DeploymentStatus.Success, response.Status);
}
// ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
// ── ParentExecutionId: inbound-API routing ──
/// <summary>Verifies that an inbound API call carrying a parent execution id is routed to the script successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
{
@@ -504,6 +530,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
}
/// <summary>Verifies that an inbound API call without a parent execution id is still routed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
{
@@ -530,6 +558,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
// ── Spec §6 (WD-2b): routed RouteToWaitForAttributeRequest → InstanceActor ──
/// <summary>Verifies that a routed wait replies matched when the attribute is already at the target value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiWaitForAttribute_AttributeAlreadyAtTarget_RepliesMatched()
{
@@ -562,10 +592,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("Good", response.Quality);
}
/// <summary>Verifies that the require-good-quality flag is passed through to the instance actor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiWaitForAttribute_RequireGoodQuality_IsThreadedToInstanceActor()
{
// #14 (full fidelity): the additive RouteToWaitForAttributeRequest.RequireGoodQuality
// Full fidelity: the additive RouteToWaitForAttributeRequest.RequireGoodQuality
// flag must reach the InstanceActor's wait. A static attribute is Good quality, so a
// quality-gated wait whose target equals the current value still matches — proving the
// flag threads through (and does not spuriously reject a Good value).
@@ -591,6 +623,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("Good", response.Quality);
}
/// <summary>Verifies that a routed wait for an unknown instance replies not found.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound()
{
@@ -612,10 +646,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a routed wait matching a list value returns a serializer-safe value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiWaitForAttribute_ListValuedMatch_ReturnsSerializerSafeValue()
{
// WS-4: a routed wait whose matched attribute is a List-typed value comes
// A routed wait whose matched attribute is a List-typed value comes
// back from the Instance Actor as a concrete generic List<int> — a shape the
// cross-process (Newtonsoft) serializer cannot reliably round-trip, which would
// otherwise silently drop the reply and hang the caller's Ask. The handler must
@@ -658,15 +694,17 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
}
// ── #162: routed RouteToGetAttributesRequest → InstanceActor (List normalization) ──
// ── Routed RouteToGetAttributesRequest → InstanceActor (List normalization) ──
/// <summary>Verifies that a routed get-attributes read returns a serializer-safe list value and leaves scalar values unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiGetAttributes_ListValuedAttribute_ReturnsSerializerSafeValueAndScalarUnchanged()
{
// #162: a routed GetAttributes read of a List-typed static attribute comes back
// A routed GetAttributes read of a List-typed static attribute comes back
// from the Instance Actor as a concrete generic List<int> — the SAME non-primitive
// shape the cross-process (Newtonsoft) serializer cannot reliably round-trip that
// WS-4 fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and
// was already fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and
// the caller's Route.To().GetAttributes() Ask hangs to timeout. The handler must
// project EACH value through NormalizeRoutedReturnValue so the List survives the
// wire — while leaving scalar/string/null values unchanged (no regression).
@@ -714,6 +752,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Null(response.Values["DoesNotExist"]);
}
/// <summary>Verifies that a routed get-attributes read for an unknown instance replies not found.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteInboundApiGetAttributes_UnknownInstance_RepliesNotFound()
{
@@ -734,8 +774,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
// ── Debug-view routing — unknown-instance not-found signal ──
/// <summary>Verifies that a debug snapshot request for an unknown instance sets the instance-not-found flag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteDebugSnapshot_UnknownInstance_SetsInstanceNotFound()
{
@@ -755,6 +797,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Empty(snapshot.AlarmStates);
}
/// <summary>Verifies that a debug view subscribe for an unknown instance sets the instance-not-found flag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteDebugViewSubscribe_UnknownInstance_SetsInstanceNotFound()
{
@@ -772,6 +816,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Empty(snapshot.AlarmStates);
}
/// <summary>Verifies that a debug snapshot for a known but empty instance does not set the instance-not-found flag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteDebugSnapshot_KnownButEmptyInstance_DoesNotSetInstanceNotFound()
{
@@ -794,8 +840,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
"A deployed (but empty) instance must NOT set InstanceNotFound.");
}
// ── Task 10: notify-and-fetch — RefreshDeploymentCommand → fetch → apply ──
// ── Notify-and-fetch — RefreshDeploymentCommand → fetch → apply ──
/// <summary>Verifies that a refresh whose fetch succeeds applies the configuration and replies success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RefreshDeployment_FetchSucceeds_AppliesConfigAndRepliesSuccess()
{
@@ -825,6 +873,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Contains(configs, c => c.InstanceUniqueName == "FetchedPump");
}
/// <summary>Verifies that a refresh whose fetch fails replies failed and applies nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RefreshDeployment_FetchFails_RepliesFailedAndDoesNotApply()
{
@@ -852,6 +902,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "UnfetchedPump");
}
/// <summary>Verifies that a refresh with no fetcher replies failed.</summary>
[Fact]
public void RefreshDeployment_NullFetcher_RepliesFailed()
{
@@ -868,6 +919,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("Pump", response.InstanceUniqueName);
}
/// <summary>Verifies that the refresh reply goes to the original sender across the asynchronous fetch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RefreshDeployment_ReplyGoesToOriginalSender_AcrossAsyncFetch()
{
@@ -889,13 +942,15 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("SenderPump", response.InstanceUniqueName);
}
// ── LocalDb Phase 2 / Task 12: the active-node central-only purge ──
// ── LocalDb Phase 2: the active-node central-only purge ──
/// <summary>Verifies that applying artifacts purges the central-only notification configuration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyingArtifacts_PurgesCentralOnlyNotificationConfig()
{
// Security cleanup. notification_lists and smtp_configurations can hold plaintext
// SMTP passwords written by a pre-2026-07-10 build, and the ACTIVE node's artifact
// SMTP passwords written by a build older than 2026-07-10, and the ACTIVE node's artifact
// apply is what clears them (DeploymentManagerActor.HandleDeployArtifacts). The
// standby used to hold a second copy of this call in SiteReplicationActor; LocalDb
// Phase 2 deleted that actor, so this is now the ONLY call site that keeps the
@@ -1004,6 +1059,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
/// policy was dropped on the floor here and every cached call fell back to the engine's
/// 50 x 30 s default.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyingArtifacts_PersistsExternalSystemRetryPolicy()
{
@@ -1026,6 +1082,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal(45_000, reader.GetInt64(1));
}
/// <summary>Verifies that applying artifacts deletes external systems that are absent from the set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyingArtifacts_DeletesExternalSystemsAbsentFromTheSet()
{
@@ -1049,6 +1107,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal(1, await RowCountAsync("external_systems"));
}
/// <summary>Verifies that applying artifacts with a null set touches nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyingArtifacts_NullSet_TouchesNothing()
{
@@ -1067,6 +1127,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal(1, await RowCountAsync("external_systems"));
}
/// <summary>Verifies that a removed shared script is unregistered from the library.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyingArtifacts_RemovedSharedScript_IsUnregisteredFromLibrary()
{
@@ -1093,6 +1155,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.False(_sharedScriptLibrary.Contains("GoneScript"));
}
/// <summary>Verifies that a removed data connection stops its live connection in the data connection layer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyingArtifacts_RemovedDataConnection_StopsLiveDclConnection()
{
@@ -1134,13 +1198,26 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
private readonly string? _result;
private readonly Exception? _error;
/// <summary>Initializes the fetcher to return the given configuration JSON.</summary>
/// <param name="result">The configuration JSON returned from every fetch.</param>
public FakeConfigFetcher(string result) => _result = result;
/// <summary>Initializes the fetcher to throw the given exception.</summary>
/// <param name="error">The exception thrown from every fetch.</param>
public FakeConfigFetcher(Exception error) => _error = error;
/// <summary>Gets the central base URL passed to the most recent fetch.</summary>
public string? LastBaseUrl { get; private set; }
/// <summary>Gets the deployment id passed to the most recent fetch.</summary>
public string? LastDeploymentId { get; private set; }
/// <summary>Gets the token passed to the most recent fetch.</summary>
public string? LastToken { get; private set; }
/// <summary>Records the request arguments and returns the canned JSON or throws the canned exception.</summary>
/// <param name="centralFetchBaseUrl">The central base URL being fetched from.</param>
/// <param name="deploymentId">The deployment id being fetched.</param>
/// <param name="token">The deployment token.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task yielding the canned configuration JSON.</returns>
public async Task<string> FetchAsync(
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
{
@@ -49,6 +49,7 @@ akka {
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates the temporary local database, storage service and script services the deployment manager needs.</summary>
public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile")
{
_localDb = TestLocalDb.CreateTemp("dm-cert-test");
@@ -61,6 +62,7 @@ akka {
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -74,6 +76,8 @@ akka {
/// <summary>Forwards every message it receives to a probe, preserving the original sender.</summary>
private sealed class ForwardingActor : ReceiveActor
{
/// <summary>Creates a forwarder that sends every message it receives to the given target.</summary>
/// <param name="target">The actor that receives the forwarded messages.</param>
public ForwardingActor(IActorRef target) => ReceiveAny(msg => target.Forward(msg));
}
@@ -82,6 +86,7 @@ akka {
_storage, _compilationService, _sharedScriptLibrary,
null, new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance)));
/// <summary>Verifies that when a site node joins, the local trusted certificates are pushed to the joined node.</summary>
[Fact]
public void SiteNodeJoined_PushesLocalTrustedCertsToJoinedNode()
{
@@ -14,7 +14,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must
/// Regression test: <see cref="DeploymentManagerActor"/> must
/// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it
/// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per
/// instance.
@@ -26,6 +26,7 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with an actor system.</summary>
public DeploymentManagerLoggerFactoryTests()
{
_localDb = TestLocalDb.CreateTemp("dm-loggerfactory-test");
@@ -39,6 +40,7 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -72,16 +74,24 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
public int CreateLoggerCalls;
public bool Disposed;
/// <summary>Counts the call and returns a null logger.</summary>
/// <param name="categoryName">The logger category name.</param>
/// <returns>A logger that discards entries.</returns>
public ILogger CreateLogger(string categoryName)
{
Interlocked.Increment(ref CreateLoggerCalls);
return NullLogger.Instance;
}
/// <summary>Ignores the provider.</summary>
/// <param name="provider">The provider (ignored).</param>
public void AddProvider(ILoggerProvider provider) { }
/// <summary>Records that the factory was disposed.</summary>
public void Dispose() => Disposed = true;
}
/// <summary>Verifies that creating instance actors reuses the injected logger factory for every instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
{
@@ -15,8 +15,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for the Medium-severity DeploymentManagerActor findings:
/// SiteRuntime-005 (Success reported before persistence completes) and
/// SiteRuntime-008 (blocking shared-script load on the actor thread).
/// success reported before persistence completes, and
/// blocking shared-script load on the actor thread.
/// </summary>
public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
{
@@ -24,6 +24,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of the <c>DeploymentManagerMediumFindingsTests</c> class.</summary>
public DeploymentManagerMediumFindingsTests()
{
_localDb = TestLocalDb.CreateTemp("dm-medium-test");
@@ -33,6 +34,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -98,11 +100,12 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
}
/// <summary>
/// SiteRuntime-005: when SQLite persistence of the deployed config fails, the
/// When SQLite persistence of the deployed config fails, the
/// Deployment Manager must report <see cref="DeploymentStatus.Failed"/> to central,
/// not <see cref="DeploymentStatus.Success"/>. Reporting Success on a persistence
/// failure silently loses the deployment on the next restart/failover.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess()
{
@@ -140,10 +143,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
}
/// <summary>
/// SiteRuntime-005: a successful deployment must still report
/// A successful deployment must still report
/// <see cref="DeploymentStatus.Success"/>, and only after the config row is
/// committed to SQLite (so a restart re-creates the instance).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deploy_Success_ReportsSuccessAndPersistsConfig()
{
@@ -165,7 +169,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
}
/// <summary>
/// SiteRuntime-010: when a redeployment changes a connection's configuration
/// When a redeployment changes a connection's configuration
/// (here the failover retry count and endpoint), the Deployment Manager must push the new
/// configuration to the DCL rather than leaving it serving the stale one.
///
@@ -177,6 +181,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
/// which the live connection actor actually applies in place.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesReconfigureCommand()
{
@@ -214,9 +219,10 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
}
/// <summary>
/// SiteRuntime-010: an unchanged connection configuration must still be skipped —
/// An unchanged connection configuration must still be skipped —
/// re-sending an identical create command on every deploy is wasteful.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand()
{
@@ -245,10 +251,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
}
/// <summary>
/// SiteRuntime-008: startup must not block the Deployment Manager mailbox on a
/// Startup must not block the Deployment Manager mailbox on a
/// synchronous shared-script load. With shared scripts present, the actor must
/// still load deployed configs, create Instance Actors, and remain responsive.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive()
{
@@ -17,7 +17,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for SiteRuntime-003: redeployment of an existing instance must
/// Regression tests: redeployment of an existing instance must
/// wait for the terminating Instance Actor before recreating the child, instead of
/// relying on a fixed 500 ms reschedule that can collide on the child actor name.
/// </summary>
@@ -28,6 +28,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a temporary LocalDb, site storage and script compilation services for the tests.</summary>
public DeploymentManagerRedeployTests()
{
_localDb = TestLocalDb.CreateTemp("dm-redeploy-test");
@@ -41,6 +42,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -95,25 +97,45 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
/// </summary>
private sealed class CountCapturingHealthCollector : ISiteHealthCollector
{
/// <summary>Gets the most recently reported deployed-instance count.</summary>
public int LastDeployedCount { get; private set; }
/// <inheritdoc />
public void IncrementScriptError() { }
/// <inheritdoc />
public void IncrementAlarmError() { }
/// <inheritdoc />
public void IncrementDeadLetter() { }
/// <inheritdoc />
public void IncrementSiteAuditWriteFailures() { }
/// <inheritdoc />
public void IncrementAuditRedactionFailure() { }
/// <inheritdoc />
public void UpdateSiteAuditBacklog(ZB.MOM.WW.ScadaBridge.Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
/// <inheritdoc />
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
/// <inheritdoc />
public void RemoveConnection(string connectionName) { }
/// <inheritdoc />
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
/// <inheritdoc />
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
/// <inheritdoc />
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
/// <inheritdoc />
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
/// <inheritdoc />
public void SetInstanceCounts(int deployed, int enabled, int disabled) => LastDeployedCount = deployed;
/// <inheritdoc />
public void SetParkedMessageCount(int count) { }
/// <inheritdoc />
public void SetNodeHostname(string hostname) { }
/// <inheritdoc />
public void SetClusterNodes(IReadOnlyList<NodeStatus> nodes) { }
/// <inheritdoc />
public void SetActiveNode(bool isActive) { }
/// <inheritdoc />
public bool IsActiveNode => true;
/// <inheritdoc />
public SiteHealthReport CollectReport(string siteId) => throw new NotSupportedException();
}
@@ -130,6 +152,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
/// <summary>Verifies that redeploying an existing instance succeeds without a child actor name collision.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Redeploy_ExistingInstance_SucceedsWithoutNameCollision()
{
@@ -155,10 +179,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
Assert.True(disable.Success);
}
/// <summary>Verifies that three rapid deploys do not throw an invalid-actor-name exception and the latest deploy wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR020_ThreeRapidDeploys_DoNotThrowInvalidActorNameException_LatestWins()
{
// Regression test for SiteRuntime-020. The previous implementation tracked
// Regression test: the previous implementation tracked
// pending redeploys by IActorRef (_pendingRedeploys) but had no
// name-keyed shadow, so a third DeployInstanceCommand arriving WHILE the
// first redeploy's predecessor was still terminating saw
@@ -184,7 +210,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
// Two rapid redeploys before the predecessor has time to fully terminate.
// The second deploy stops the actor (watching it) and buffers itself.
// The third deploy arrives almost immediately and must NOT crash — it
// overwrites the buffered pending command and tells dep-2 it was superseded.
// overwrites the buffered pending command and tells the first deployment it was superseded.
var probe2 = CreateTestProbe();
var probe3 = CreateTestProbe();
@@ -195,7 +221,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
"dep-3", "RapidPump", "h3", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow),
probe3.Ref);
// dep-2 must be told it was superseded; dep-3 must succeed once the
// The first deployment must be told it was superseded; the second must succeed once the
// predecessor finishes terminating.
var superseded = probe2.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("dep-2", superseded.DeploymentId);
@@ -214,10 +240,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
Assert.True(disable.Success);
}
/// <summary>Verifies that a delete during a pending redeploy leaves the instance deleted and the deployed count correct.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR029_DeleteDuringPendingRedeploy_InstanceStaysDeleted_AndCounterIsCorrect()
{
// Regression test for SiteRuntime-029. A delete arriving WHILE a redeploy is
// Regression test: a delete arriving WHILE a redeploy is
// still terminating used to: (1) over-decrement _totalDeployedCount, and
// (2) leave the buffered _pendingRedeploys entry intact — so when Terminated
// fired, HandleTerminated called ApplyDeployment(isRedeploy: true) and
@@ -241,7 +269,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
// singleton mailbox: HandleDeploy runs first (removes from _instanceActors,
// watches + stops the predecessor, buffers the redeploy, sets the terminating
// shadow), then HandleDelete runs while the predecessor is still terminating
// (Terminated has not fired) — exactly the SiteRuntime-029 window.
// (Terminated has not fired) — exactly the delete-during-redeploy window.
var redeployProbe = CreateTestProbe();
actor.Tell(new DeployInstanceCommand(
"dep-2", "RaceTarget", "h2", MakeConfigJson("RaceTarget"), "admin", DateTimeOffset.UtcNow),
@@ -270,10 +298,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
Assert.Equal(0, health.LastDeployedCount);
}
/// <summary>Verifies that deleting a disabled instance decrements the deployed count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR032_DeleteDisabledInstance_DecrementsDeployedCount()
{
// Regression test for SiteRuntime-032. SiteRuntime-029 gated the deployed-count
// Regression test: the earlier delete-during-redeploy fix gated the deployed-count
// decrement on the instance being present in _instanceActors OR mid-redeploy in
// _terminatingActorsByName. A DISABLED instance is in NEITHER map (disable removes
// it from _instanceActors and never adds it to the terminating shadow) yet still has
@@ -300,7 +330,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
Assert.Equal(1, health.LastDeployedCount);
// Delete the DISABLED instance → the deployed count must return to 0.
// (The SiteRuntime-029 regression left it stuck at 1.)
// (The delete-during-redeploy regression left it stuck at 1.)
actor.Tell(new DeleteInstanceCommand("del-1", "DisablePump", DateTimeOffset.UtcNow));
var delete = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(delete.Success);
@@ -311,6 +341,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "DisablePump");
}
/// <summary>Verifies that redeploying an existing instance does not over-count deployed instances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Redeploy_ExistingInstance_DoesNotOverCountDeployedInstances()
{
@@ -344,6 +376,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
// ── S6: an Instance Actor that dies during init must not linger as a dead ref ──
/// <summary>Verifies that an Instance Actor init failure at startup evicts the dead reference and logs a site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActorInitFailure_OnStartup_EvictsDeadRefAndLogsSiteEvent()
{
@@ -367,6 +401,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that a deploy whose actor dies during initialization reports failure rather than success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deploy_WhoseActorDiesDuringInit_ReportsFailed_NotSuccess()
{
@@ -27,6 +27,7 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary>
public DeploymentManagerRouteTraceTests()
{
_localDb = TestLocalDb.CreateTemp("dm-route-trace-test");
@@ -36,6 +37,7 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -67,6 +69,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
return actor;
}
/// <summary>Verifies that a traced route call returns the child execution id and the inline trace events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteCall_WithTrace_ReturnsChildExecutionId_AndInlineTraceEvents()
{
@@ -92,6 +96,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
Assert.All(rows, r => Assert.Equal(response.ChildExecutionId, r.ExecutionId));
}
/// <summary>Verifies that an untraced route call returns the execution id but no trace events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteCall_WithoutTrace_ReturnsExecutionId_ButNoTraceEvents()
{
@@ -105,6 +111,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
Assert.Null(response.TraceEvents);
}
/// <summary>Verifies that routing a set-attributes request forwards the trace flag on the command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RouteSetAttributes_ForwardsTraceFlag_OnTheCommand()
{
@@ -15,12 +15,12 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 9 — warm-then-gate deploys and startup batch pre-warm.
/// Warm-then-gate deploys and startup batch pre-warm.
///
/// <para>The site-side compile gate (S3) must stay synchronous on the Deployment Manager's
/// thread, because redeploy-supersede and delete-during-redeploy both depend on strict mailbox
/// FIFO. But the Roslyn compile it performs used to hold the singleton for the whole
/// compilation, stalling every OTHER instance's commands behind one instance's scripts. WP3.1
/// compilation, stalling every OTHER instance's commands behind one instance's scripts. The new flow
/// warms the compile off-thread first and then re-runs the gate as pure cache hits, with a
/// per-instance in-flight guard preserving same-instance ordering.</para>
///
@@ -44,6 +44,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of the <see cref="DeploymentWarmThenGateTests"/> class.</summary>
public DeploymentWarmThenGateTests()
{
_localDb = TestLocalDb.CreateTemp("dm-warm-gate-test");
@@ -56,6 +57,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -95,6 +97,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
{
private int _reports;
/// <summary>Gets the deployed instance count from the most recent <c>SetInstanceCounts</c> call.</summary>
public int LastDeployedCount { get; private set; }
/// <summary>
@@ -107,29 +110,48 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
/// value and proves nothing about the manager having started at all.
/// </summary>
public int ReportCount => Volatile.Read(ref _reports);
/// <inheritdoc />
public void IncrementScriptError() { }
/// <inheritdoc />
public void IncrementAlarmError() { }
/// <inheritdoc />
public void IncrementDeadLetter() { }
/// <inheritdoc />
public void IncrementSiteAuditWriteFailures() { }
/// <inheritdoc />
public void IncrementAuditRedactionFailure() { }
/// <inheritdoc />
public void UpdateSiteAuditBacklog(Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
/// <inheritdoc />
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
/// <inheritdoc />
public void RemoveConnection(string connectionName) { }
/// <inheritdoc />
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
/// <inheritdoc />
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
/// <inheritdoc />
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
/// <inheritdoc />
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
/// <inheritdoc />
public void SetInstanceCounts(int deployed, int enabled, int disabled)
{
LastDeployedCount = deployed;
Interlocked.Increment(ref _reports);
}
/// <inheritdoc />
public void SetParkedMessageCount(int count) { }
/// <inheritdoc />
public void SetNodeHostname(string hostname) { }
/// <inheritdoc />
public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) { }
/// <inheritdoc />
public void SetActiveNode(bool isActive) { }
/// <inheritdoc />
public bool IsActiveNode => true;
/// <inheritdoc />
public Commons.Messages.Health.SiteHealthReport CollectReport(string siteId)
=> throw new NotSupportedException();
}
@@ -147,6 +169,8 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
: [new ResolvedScript { CanonicalName = "Worker", Code = scriptCode, TriggerType = "Call" }]
});
/// <summary>Verifies that a delete arriving during the compile warm is queued and applied after the deploy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteArrivingDuringTheCompileWarm_IsQueuedAndAppliedAfterTheDeploy()
{
@@ -177,10 +201,12 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
// ahead of the warm it would have removed nothing and the deploy would have left the
// count at 1. This is the ordering signal rather than the SQLite row, because the
// deploy's store and the delete's remove are independent background tasks whose
// completion order the actor has never guaranteed (true before WP3.1 as well).
// completion order the actor has never guaranteed (true before warm-then-gate as well).
AwaitAssert(() => Assert.Equal(0, health.LastDeployedCount), TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that a second deploy during the warm supersedes the first and answers its deployer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecondDeployDuringTheWarm_SupersedesTheFirst_AndAnswersItsDeployer()
{
@@ -212,6 +238,8 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
Assert.Equal("h2", row.RevisionHash);
}
/// <summary>Verifies that deploys for different instances do not block each other.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploysForDifferentInstances_DoNotBlockEachOther()
{
@@ -239,12 +267,14 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
Assert.Contains(configs, c => c.InstanceUniqueName == "PumpY");
}
/// <summary>Verifies that staggered startup pre-warms each batch so the Instance Actor PreStart compiles are cache hits.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaggeredStartup_PreWarmsEachBatchSoInstanceActorPreStartCompilesAreCacheHits()
{
// Two instances sharing one script body. The batch pre-warm compiles it once; the
// second config's warm and BOTH Instance Actors' PreStart compiles are then hits.
// Before WP3.1 every Instance Actor Roslyn-compiled its own scripts inside PreStart,
// Before the batch pre-warm every Instance Actor Roslyn-compiled its own scripts inside PreStart,
// serialising a site's whole failover recovery behind compilation.
const string sharedCode = "return 41 + 1;";
await _storage.StoreDeployedConfigAsync(
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
/// End-to-end test over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, the script's reset
/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the
/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until
@@ -33,6 +33,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test with a temporary LocalDb-backed site storage and script compilation services.</summary>
public HandshakeResetEchoTests()
{
_localDb = TestLocalDb.CreateTemp("handshake-reset-echo");
@@ -42,6 +43,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -90,6 +92,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
/// cache — callers migrate to the reset-aware overload instead. Never "fix" the fast-path in
/// place; delete this test only if the legacy overload itself is removed.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue()
{
@@ -106,6 +109,8 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
Assert.True(await call);
}
/// <summary>Verifies that the reset-aware overload holds the batch until the reset echoes and then matches the fresh true value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue()
{
@@ -151,6 +156,8 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
Assert.Equal("Good", result.Quality);
}
/// <summary>Verifies that the reset-aware overload times out in the reset phase when no echo arrives and never writes the batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch()
{
@@ -14,7 +14,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression coverage for SiteRuntime-017 — the Instance Actor must not hand its
/// Regression coverage: the Instance Actor must not hand its
/// own live mutable <c>_attributes</c> dictionary by reference into the
/// <see cref="ScriptActor"/> / <see cref="AlarmActor"/> constructors.
///
@@ -42,6 +42,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the temporary database, storage, compilation service and options used by the tests.</summary>
public InstanceActorChildAttributeRaceTests()
{
_localDb = TestLocalDb.CreateTemp("instance-race-test");
@@ -60,6 +61,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
};
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -164,6 +166,8 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
return (Dictionary<string, object?>)field!.GetValue(instance)!;
}
/// <summary>Verifies that child actors are seeded from an isolated copy of the attributes, not the live attributes dictionary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary()
{
@@ -192,7 +196,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
// Every child Script Actor must have been seeded from a dictionary that
// is NOT the Instance Actor's live _attributes field — otherwise the
// child constructor would enumerate a dictionary the Instance Actor
// mutates on another thread (SiteRuntime-017).
// mutates on another thread.
foreach (var name in new[] { "WorkerA", "WorkerB" })
{
var scriptActor = await ResolveChildInstanceAsync<ScriptActor>(testRef, $"script-{name}");
@@ -11,7 +11,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// PLAN-03 Task 18 (P2): the Instance Actor routes each attribute change only to the
/// The Instance Actor routes each attribute change only to the
/// children interested in it. A value-change / conditional script (and a monitored-attribute
/// alarm) subscribes to a single named attribute; an Expression-triggered script/alarm hears
/// every change (it keeps a full attribute snapshot). These tests pin the routing-map
@@ -25,6 +25,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly TestLocalDb _localDb;
/// <summary>Creates the test class with a fresh storage service and actor system.</summary>
public InstanceActorChildRoutingTests()
{
_localDb = TestLocalDb.CreateTemp("instance-routing-test");
@@ -43,6 +44,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
};
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -101,6 +103,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
return testRef.UnderlyingActor;
}
/// <summary>Verifies that the routing maps are built from the trigger configurations.</summary>
[Fact]
public void RoutingMaps_AreBuiltFromTriggerConfigs()
{
@@ -113,6 +116,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
Assert.Single(actor.AllChangeChildren);
}
/// <summary>Verifies that a monitored-attribute alarm routes only to its attribute while an expression alarm routes to all changes.</summary>
[Fact]
public void MonitoredAttributeAlarm_RoutesToItsAttribute_ExpressionAlarm_RoutesToAllChanges()
{
@@ -15,7 +15,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Integration tests for InstanceActor with child Script/Alarm actors (WP-15, WP-16, WP-24, WP-25).
/// Integration tests for InstanceActor with child Script/Alarm actors.
/// </summary>
public class InstanceActorIntegrationTests : TestKit, IDisposable
{
@@ -25,6 +25,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly TestLocalDb _localDb;
/// <summary>Creates the temporary local database, storage service, script services and runtime options the tests share.</summary>
public InstanceActorIntegrationTests()
{
_localDb = TestLocalDb.CreateTemp("instance-int-test");
@@ -44,6 +45,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -82,6 +84,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
NullLogger<InstanceActor>.Instance)));
}
/// <summary>Verifies that the instance actor creates script actors from its configuration.</summary>
[Fact]
public void InstanceActor_CreatesScriptActors_FromConfig()
{
@@ -103,6 +106,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
Assert.Equal(42, result.ReturnValue);
}
/// <summary>Verifies that calling an unknown script returns an error.</summary>
[Fact]
public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError()
{
@@ -114,10 +118,11 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
Assert.Contains("not found", result.ErrorMessage);
}
/// <summary>Verifies that rapid attribute changes are serialized through the actor mailbox without corrupting state.</summary>
[Fact]
public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox()
{
// WP-24: Instance Actor processes messages sequentially.
// Instance Actor processes messages sequentially.
// Verify that rapid attribute changes don't corrupt state.
var actor = CreateInstanceWithScripts("Pump1");
@@ -143,6 +148,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
Assert.Equal("49", response.Value?.ToString());
}
/// <summary>Verifies that subscribing to the debug view returns a snapshot.</summary>
[Fact]
public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot()
{
@@ -159,6 +165,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue);
}
/// <summary>Verifies that a debug view subscription no longer forwards events through the cluster client.</summary>
[Fact]
public void InstanceActor_WP25_DebugViewSubscribe_NoLongerForwardsEventsViaClusterClient()
{
@@ -179,6 +186,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that unsubscribing from the debug view stops notifications.</summary>
[Fact]
public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications()
{
@@ -199,6 +207,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that the instance actor creates alarm actors from its configuration.</summary>
[Fact]
public void InstanceActor_CreatesAlarmActors_FromConfig()
{
@@ -29,6 +29,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options = new();
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with a temporary storage setup.</summary>
public InstanceActorMesReportableSnapshotTests()
{
_localDb = TestLocalDb.CreateTemp("instance-mes-reportable");
@@ -99,6 +100,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that the alarm snapshot stamps the MES-reportable flag on both fired and unfired alarms.</summary>
[Fact]
public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms()
{
@@ -136,6 +138,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
Assert.False(unfired.IsMesReportable);
}
/// <summary>Verifies that an unfired reportable alarm is stamped before it ever fires.</summary>
[Fact]
public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires()
{
@@ -151,6 +154,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -18,7 +18,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Task 16: InstanceActor spawns NativeAlarmActor children from the flattened
/// InstanceActor spawns NativeAlarmActor children from the flattened
/// configuration and surfaces enriched native alarm state in the DebugView snapshot.
/// </summary>
public class InstanceActorNativeAlarmTests : TestKit, IDisposable
@@ -29,6 +29,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options = new();
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class and its test doubles.</summary>
public InstanceActorNativeAlarmTests()
{
_localDb = TestLocalDb.CreateTemp("instance-native");
@@ -64,6 +65,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
]
};
/// <summary>Verifies that the instance actor spawns a native alarm actor that subscribes through the DCL.</summary>
[Fact]
public void SpawnsNativeAlarmActor_WhichSubscribesViaDcl()
{
@@ -76,6 +78,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
Assert.Equal("ns=2;s=T01", req.SourceReference);
}
/// <summary>Verifies that the debug view snapshot includes the native alarm after a transition.</summary>
[Fact]
public void DebugViewSnapshot_IncludesNativeAlarm_AfterTransition()
{
@@ -97,6 +100,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800);
}
/// <summary>Verifies that a quiet native binding emits a placeholder in the snapshot.</summary>
[Fact]
public void Snapshot_QuietNativeBinding_EmitsPlaceholder()
{
@@ -119,6 +123,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
Assert.Equal(AlarmKind.NativeOpcUa, placeholder.Kind);
}
/// <summary>Verifies that a native binding with a live condition emits no placeholder.</summary>
[Fact]
public void Snapshot_NativeBindingWithLiveCondition_NoPlaceholder()
{
@@ -127,7 +132,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// The NativeAlarmActor emits a live condition stamped with the binding's
// canonical name (DV-1: NativeSourceCanonicalName), so the binding is "active".
// canonical name (NativeSourceCanonicalName), so the binding is "active".
actor.Tell(new AlarmStateChanged("inst", "Pressure.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
{
Kind = AlarmKind.NativeOpcUa,
@@ -151,6 +156,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
// ── MES alarm-status API §5.2: the script-facing alarm snapshot ────────
/// <summary>Verifies that GetAlarmSnapshot returns the same rows as the debug view snapshot.</summary>
[Fact]
public void GetAlarmSnapshot_ReturnsTheSameRowsAsTheDebugViewSnapshot()
{
@@ -190,6 +196,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
reply.Alarms.Select(a => a.AlarmName).OrderBy(n => n));
}
/// <summary>Verifies that GetAlarmSnapshot with no alarms replies with only the configured placeholders.</summary>
[Fact]
public void GetAlarmSnapshot_WithNoAlarms_RepliesWithTheConfiguredPlaceholdersOnly()
{
@@ -206,6 +213,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
Assert.Null(placeholder.AckTime);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -14,7 +14,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for SiteRuntime-001: Instance.SetAttribute must route writes
/// Regression tests: Instance.SetAttribute must route writes
/// to the Data Connection Layer for data-sourced attributes instead of persisting
/// a local static override.
/// </summary>
@@ -26,6 +26,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with an actor system and storage.</summary>
public InstanceActorSetAttributeTests()
{
_localDb = TestLocalDb.CreateTemp("instance-setattr-test");
@@ -40,6 +41,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
_options = new SiteRuntimeOptions();
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -87,6 +89,8 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
]
};
/// <summary>Verifies that setting a data-sourced attribute issues a DCL write and does not persist an override.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetAttribute_DataSourcedAttribute_IssuesDclWriteAndDoesNotPersistOverride()
{
@@ -114,6 +118,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
Assert.Empty(overrides);
}
/// <summary>Verifies that setting a data-sourced attribute does not optimistically update memory.</summary>
[Fact]
public void SetAttribute_DataSourcedAttribute_DoesNotOptimisticallyUpdateMemory()
{
@@ -135,6 +140,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
Assert.Equal("10", get.Value?.ToString());
}
/// <summary>Verifies that a DCL write failure for a data-sourced attribute is returned to the caller.</summary>
[Fact]
public void SetAttribute_DataSourcedAttribute_DclWriteFailure_ReturnedToCaller()
{
@@ -153,6 +159,8 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
Assert.Contains("device rejected write", response.ErrorMessage);
}
/// <summary>Verifies that setting a static attribute still persists the override and does not call the DCL.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetAttribute_StaticAttribute_StillPersistsOverrideAndDoesNotCallDcl()
{
@@ -182,10 +190,12 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
Assert.Equal("Backup", overrides["Label"]);
}
// SiteRuntime-025: SetAttribute on an unknown attribute name must NOT
// SetAttribute on an unknown attribute name must NOT
// pollute the in-memory dictionary, NOT publish a synthetic
// AttributeValueChanged, and NOT persist a durable override row.
/// <summary>Verifies that setting an unknown attribute returns a failure and does not persist an override.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride()
{
@@ -12,6 +12,7 @@ public class InstanceActorTagSubscribeBackoffTests
private static readonly TimeSpan Floor = TimeSpan.FromSeconds(5);
private static readonly TimeSpan Max = TimeSpan.FromSeconds(60);
/// <summary>Verifies the case: doubles, with No Jitter.</summary>
[Fact]
public void Doubles_WithNoJitter()
{
@@ -19,6 +20,7 @@ public class InstanceActorTagSubscribeBackoffTests
Assert.Equal(TimeSpan.FromSeconds(10), next);
}
/// <summary>Verifies the case: jitter, spans Plus Minus Twenty Percent.</summary>
[Fact]
public void Jitter_SpansPlusMinusTwentyPercent()
{
@@ -28,6 +30,7 @@ public class InstanceActorTagSubscribeBackoffTests
Assert.Equal(TimeSpan.FromSeconds(12), high); // 10 s × 1.2
}
/// <summary>Verifies the case: never Exceeds Ceiling, even With Positive Jitter.</summary>
[Fact]
public void NeverExceedsCeiling_EvenWithPositiveJitter()
{
@@ -35,6 +38,7 @@ public class InstanceActorTagSubscribeBackoffTests
Assert.Equal(Max, next);
}
/// <summary>Verifies the case: never Below Floor, even With Negative Jitter.</summary>
[Fact]
public void NeverBelowFloor_EvenWithNegativeJitter()
{
@@ -42,6 +46,7 @@ public class InstanceActorTagSubscribeBackoffTests
Assert.True(next >= Floor, $"{next} < floor");
}
/// <summary>Verifies the case: ceiling Below Floor, degrades To Fixed Floor.</summary>
[Fact]
public void CeilingBelowFloor_DegradesToFixedFloor()
{
@@ -28,6 +28,7 @@ public class InstanceActorTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with an in-memory site storage and the shared actor dependencies.</summary>
public InstanceActorTests()
{
_localDb = TestLocalDb.CreateTemp("instance-actor-test");
@@ -55,6 +56,7 @@ public class InstanceActorTests : TestKit, IDisposable
NullLogger<InstanceActor>.Instance)));
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -65,8 +67,9 @@ public class InstanceActorTests : TestKit, IDisposable
TestLocalDb.DeleteFiles(path);
}
// ── M1.6: site event log `instance_lifecycle` category ──────────────────
// ── Site event log `instance_lifecycle` category ──────────────────
/// <summary>Verifies that stopping the actor releases its tag subscription under its owner identity.</summary>
[Fact]
public void Stop_ReleasesTagSubscriptionWithOwnerIdentity()
{
@@ -78,6 +81,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal(instance, dcl.LastSender);
}
/// <summary>Verifies that starting an instance actor emits an instance lifecycle site event.</summary>
[Fact]
public void InstanceActor_Start_EmitsInstanceLifecycleSiteEvent()
{
@@ -112,6 +116,7 @@ public class InstanceActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that stopping an instance actor emits an instance lifecycle site event.</summary>
[Fact]
public void InstanceActor_Stop_EmitsInstanceLifecycleSiteEvent()
{
@@ -152,6 +157,7 @@ public class InstanceActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that the instance actor loads its attributes from the configuration.</summary>
[Fact]
public void InstanceActor_LoadsAttributesFromConfig()
{
@@ -177,6 +183,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("corr-1", response.CorrelationId);
}
/// <summary>Verifies that getting an unknown attribute reports not found.</summary>
[Fact]
public void InstanceActor_GetAttribute_NotFound_ReturnsFalse()
{
@@ -196,6 +203,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Null(response.Value);
}
/// <summary>Verifies that setting a static attribute updates it in memory.</summary>
[Fact]
public void InstanceActor_SetStaticAttribute_UpdatesInMemory()
{
@@ -225,6 +233,8 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("100.0", getResponse.Value?.ToString());
}
/// <summary>Verifies that setting a static attribute persists it to SQLite.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_SetStaticAttribute_PersistsToSQLite()
{
@@ -254,7 +264,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// Perf remediation (arch-review WP1.5): a burst of rapid static-attribute writes must
/// Perf remediation: a burst of rapid static-attribute writes must
/// coalesce into ONE batched SQLite transaction on a short single-shot timer — mirroring
/// NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape — rather than a write
/// per <see cref="SetStaticAttributeCommand"/>. Pinned two ways: (1) nothing is persisted
@@ -262,6 +272,7 @@ public class InstanceActorTests : TestKit, IDisposable
/// synchronously and updated in-memory state; (2) only the LATEST value per attribute
/// survives, across writes to two different attributes coalesced into the same flush.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_RapidStaticWrites_CoalesceIntoSingleFlush()
{
@@ -324,6 +335,8 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("Running", afterFlush["Label"]);
}
/// <summary>Verifies that static overrides are loaded from SQLite.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_LoadsStaticOverridesFromSQLite()
{
@@ -353,6 +366,8 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("200.0", response.Value?.ToString());
}
/// <summary>Verifies that a static override is reset on redeployment.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaticOverride_ResetOnRedeployment()
{
@@ -364,7 +379,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Single(overrides);
// Clear overrides BEFORE the successor exists — the ordering DeploymentManager now
// guarantees (arch-review 2026-08-19 P1-2). It used to clear inside the deploy's
// guarantees. It used to clear inside the deploy's
// persistence task, i.e. after creating the actor, so this "simulation" pinned an
// ordering the production path did not have; the end-to-end contract lives in
// RedeployStaticOverrideResetTests.
@@ -394,6 +409,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("98.6", response.Value?.ToString());
}
/// <summary>Verifies that a data-sourced attribute starts with uncertain quality.</summary>
[Fact]
public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality()
{
@@ -424,7 +440,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the
/// The disable/enable lifecycle is owned entirely by the
/// Deployment Manager (it stops / re-creates the Instance Actor itself and
/// replies to the caller). The Instance Actor must NOT handle
/// <see cref="DisableInstanceCommand"/> / <see cref="EnableInstanceCommand"/>
@@ -450,6 +466,7 @@ public class InstanceActorTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a static attribute starts with good quality.</summary>
[Fact]
public void InstanceActor_StaticAttribute_StartsWithGoodQuality()
{
@@ -537,7 +554,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
}
// ── MV-8: data-sourced List attribute coercion ─────────────────────────
// ── Data-sourced List attribute coercion ─────────────────────────
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl)
{
@@ -558,7 +575,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an
/// When a data-sourced attribute is declared <c>DataType.List</c>, an
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
/// coerced into a typed <c>List&lt;int&gt;</c> whose elements match the
/// attribute's ElementDataType. The stored value must be a real list — not a
@@ -598,7 +615,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-8: array elements coming in as a different CLR type (here, strings that
/// Array elements coming in as a different CLR type (here, strings that
/// are valid integers) must still coerce to the declared element type.
/// </summary>
[Fact]
@@ -635,7 +652,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-8: an element that cannot be coerced to the declared element type must
/// An element that cannot be coerced to the declared element type must
/// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it
/// stays alive and continues to answer queries).
/// </summary>
@@ -675,7 +692,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-8 design contract: a failed coercion keeps the PRIOR value. A good
/// Design contract: a failed coercion keeps the PRIOR value. A good
/// array is delivered first and stored as a typed list; a subsequent array
/// with a non-coercible element must NOT overwrite that value — the stored
/// value stays the prior list while the quality flips to <c>Bad</c>.
@@ -718,7 +735,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
/// Guard: scalar (non-List) data-sourced attributes keep the existing
/// pass-through behaviour — a scalar value is stored unchanged.
/// </summary>
[Fact]
@@ -846,10 +863,10 @@ public class InstanceActorTests : TestKit, IDisposable
ExpectNoMsg(within);
}
// ── MV-7: static (authored) List attribute decode ──────────────────────
// ── Static (authored) List attribute decode ──────────────────────
/// <summary>
/// MV-7: a STATIC List attribute carries its default as the canonical JSON
/// A STATIC List attribute carries its default as the canonical JSON
/// array string. On load the actor must decode it to a typed list so a
/// script reading the attribute receives a real collection, not the raw
/// JSON string.
@@ -882,11 +899,12 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical
/// A SetStaticAttribute write on a List attribute decodes the canonical
/// JSON value into a typed list for in-memory reads, but the PERSISTED form
/// (SQLite static override) must remain the canonical JSON string — never a
/// CLR-list .ToString().
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString()
{
@@ -925,10 +943,11 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-7: a persisted static override for a List attribute is a canonical JSON
/// A persisted static override for a List attribute is a canonical JSON
/// string in SQLite; on load it must be decoded to a typed list, the same as
/// the config default.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_StaticListOverride_LoadsAsTypedList()
{
@@ -961,7 +980,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-7: a malformed stored List value must NOT crash the actor — it loads
/// A malformed stored List value must NOT crash the actor — it loads
/// with quality Bad and the actor stays alive and answering.
/// </summary>
[Fact]
@@ -995,7 +1014,7 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-7 guard: a scalar static attribute is unaffected by the List decode
/// Guard: a scalar static attribute is unaffected by the List decode
/// path — it still returns its raw string value.
/// </summary>
[Fact]
@@ -1021,13 +1040,14 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-7 (review fix): a SetStaticAttribute write whose value fails to decode as
/// A SetStaticAttribute write whose value fails to decode as
/// a list (e.g. truncated JSON) on a List attribute must be REJECTED — reply
/// Success=false with a clear error and persist NOTHING. The script path always
/// pre-encodes valid JSON, but the Inbound API / direct-command path can submit
/// an arbitrary value, so a malformed value must not silently null the in-memory
/// value, publish "Good" quality, and durably persist a poison override.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_SetStaticListAttribute_Malformed_Rejected_NotPersisted()
{
@@ -1067,11 +1087,12 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// MV-7 (review fix): an empty-list value "[]" decodes to a non-null empty list
/// An empty-list value "[]" decodes to a non-null empty list
/// and must be accepted (NOT mistaken for a malformed value, which also decodes
/// to null). This pins the boundary between the "clearing/empty" and "poison"
/// cases that both surface as a null decode result.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_SetStaticListAttribute_EmptyList_Accepted()
{
@@ -1108,14 +1129,15 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("[]", overrides["Labels"]);
}
// ── NJ-4: old-form List static override normalization on load ────────────
// ── Old-form List static override normalization on load ────────────
/// <summary>
/// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an
/// An OLD array-of-strings static override (<c>["10","20"]</c>) for an
/// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>)
/// when the actor loads it at startup. The in-memory read still returns the
/// typed list {10,20}; the on-disk value is normalized to native JSON.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
{
@@ -1153,10 +1175,11 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
/// A NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
/// load-time normalization must be a no-op — the on-disk value is unchanged
/// (idempotent: native → native is byte-identical, so no re-persist occurs).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
{
@@ -1191,9 +1214,10 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// NJ-4: a scalar static override is unaffected by the List normalization path —
/// A scalar static override is unaffected by the List normalization path —
/// its on-disk value is left exactly as stored (no native re-encode).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
{
@@ -1222,10 +1246,11 @@ public class InstanceActorTests : TestKit, IDisposable
}
/// <summary>
/// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
/// A malformed stored List override (truncated JSON) must NOT crash the
/// actor and must NOT be re-persisted — it loads with Bad quality (as today),
/// the actor stays alive, and the poison on-disk value is left unchanged.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
{
@@ -1298,6 +1323,7 @@ public class InstanceActorTests : TestKit, IDisposable
return (actor, dcl);
}
/// <summary>Verifies that a failed subscribe response is retried.</summary>
[Fact]
public void FailedSubscribeResponse_IsRetried()
{
@@ -1313,6 +1339,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal(first.TagPaths, retry.TagPaths);
}
/// <summary>Verifies that a lost subscribe response is retried and a success stops the retrying.</summary>
[Fact]
public void LostSubscribeResponse_IsRetried_AndSuccessStopsRetrying()
{
@@ -1331,6 +1358,7 @@ public class InstanceActorTests : TestKit, IDisposable
// --- Lever 1 (2026-08-28): in-flight guard ---
/// <summary>Verifies that a slow subscribe does not stack duplicate requests while one is in flight.</summary>
[Fact]
public void SlowSubscribe_DoesNotStackDuplicates_WhileInFlight()
{
@@ -1344,10 +1372,11 @@ public class InstanceActorTests : TestKit, IDisposable
var (_, dclProbe) = CreateInstanceWithDataSourcedAttribute(options);
dclProbe.ExpectMsg<SubscribeTagsRequest>();
// Pre-Lever-1 this window would carry ~4 more requests (one per 200 ms tick).
// Before the in-flight guard this window would carry ~4 more requests (one per 200 ms tick).
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(1000));
}
/// <summary>Verifies that a slow subscribe is re-issued once after the in-flight ceiling.</summary>
[Fact]
public void SlowSubscribe_ReissuesAfterInFlightCeiling_AndOnlyOnce()
{
@@ -1367,6 +1396,7 @@ public class InstanceActorTests : TestKit, IDisposable
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>Verifies that a reply to a superseded request is ignored and the latest request still governs.</summary>
[Fact]
public void ReplyToSupersededRequest_IsIgnored_AndLatestStillGoverns()
{
@@ -1396,6 +1426,7 @@ public class InstanceActorTests : TestKit, IDisposable
// --- Lever 1 (2026-08-28): backoff on failure, reset on success ---
/// <summary>Verifies that repeated failures back off and a success resets the interval to its floor.</summary>
[Fact]
public void RepeatedFailures_BackOff_AndSuccessResetsToFloor()
{
@@ -1431,6 +1462,7 @@ public class InstanceActorTests : TestKit, IDisposable
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(1200));
}
/// <summary>Verifies that a failure reply re-issues the request even though the in-flight guard has not expired.</summary>
[Fact]
public void FailureReply_ReissuesEvenThoughGuardHasNotExpired()
{
@@ -1453,6 +1485,7 @@ public class InstanceActorTests : TestKit, IDisposable
// --- Lever 1 (2026-08-28): Good quality resets the backoff ---
/// <summary>Verifies that a flapping Good quality does not defer the retry.</summary>
[Fact]
public void FlappingGood_DoesNotDeferTheRetry()
{
@@ -1489,6 +1522,7 @@ public class InstanceActorTests : TestKit, IDisposable
dclProbe.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromMilliseconds(50));
}
/// <summary>Verifies that the connection returning to Good resets the backoff and retries promptly.</summary>
[Fact]
public void ConnectionBackToGood_ResetsBackoff_AndRetriesPromptly()
{
@@ -1518,6 +1552,7 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(1000), $"retry took {sw.Elapsed} — backoff not reset");
}
/// <summary>Verifies that a late reply for a failed and re-issued request is ignored.</summary>
[Fact]
public void LateReplyForFailedAndReissuedRequest_IsIgnored()
{
@@ -31,6 +31,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test with a temporary site database and the script services.</summary>
public InstanceActorTraceArmTests()
{
_localDb = TestLocalDb.CreateTemp("instance-tracearm-test");
@@ -40,6 +41,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -107,6 +109,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
m => m.AttributeName == "Processed" && Equals(m.Value, value), TimeSpan.FromSeconds(10));
}
/// <summary>Verifies that a traced data write followed by its echo publishes a change stamped with the source and trace.</summary>
[Fact]
public void TracedDataWrite_ThenEcho_PublishesChangeStampedWithSourceAndTrace()
{
@@ -122,6 +125,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
Assert.True(published.SourceExecutionInferred);
}
/// <summary>Verifies that an untraced data write followed by its echo publishes an unstamped change.</summary>
[Fact]
public void UntracedDataWrite_ThenEcho_PublishesUnstampedChange()
{
@@ -183,6 +187,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
Assert.True(published.Trace);
}
/// <summary>Verifies that the trace arm is consumed only once.</summary>
[Fact]
public void Arm_IsConsumedOnce()
{
@@ -199,6 +204,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
Assert.False(second.Trace);
}
/// <summary>Verifies that an expired trace arm is ignored.</summary>
[Fact]
[Trait("Category", "Slow")]
public void Arm_Expired_IsIgnored()
@@ -213,6 +219,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
Assert.False(published.Trace);
}
/// <summary>Verifies that a traced static write publishes the trace flag.</summary>
[Fact]
public void StaticWrite_Traced_PublishesTraceFlag()
{
@@ -29,6 +29,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly TestLocalDb _localDb;
/// <summary>Initializes a new instance of the <c>InstanceActorWaitForAttributeTests</c> class.</summary>
public InstanceActorWaitForAttributeTests()
{
_localDb = TestLocalDb.CreateTemp("instance-waitfor-test");
@@ -56,6 +57,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
NullLogger<InstanceActor>.Instance)));
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -474,7 +476,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
// The pending length>=3 waiter must NOT fire on this non-change republish.
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// A REAL change to a length-3 list resolves the still-pending waiter.
// A REAL change to a three-element list resolves the still-pending waiter.
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 7, 8, 9 }, QualityCode.Good, DateTimeOffset.UtcNow));
var realChange = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.Equal("wfa-len3", realChange.CorrelationId);
@@ -49,6 +49,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site store and the compilation services for each test.</summary>
public InstanceSweepSelfHealTests()
{
_localDb = TestLocalDb.CreateTemp("instance-sweep-test");
@@ -60,6 +61,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
@@ -191,7 +193,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
// ── Row 78: a config that arrives with no deploy, no enable and no reconcile ──
/// <summary>
/// THE row-78 reproduction. The node boots with an empty deployed-config store (so the
/// THE reproduction of the missing-start bug. The node boots with an empty deployed-config store (so the
/// boot-time load starts nothing), and the config for an ENABLED instance then appears in
/// storage on its own — exactly what LocalDb's CDC sync applier does when it seeds a fresh
/// node from its peer. No <see cref="DeployInstanceCommand"/>, no
@@ -202,6 +204,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// snapshot still answers <c>InstanceNotFound = true</c>, the live symptom verbatim.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_StartsEnabledInstance_WhoseConfigArrivedWithoutADeployOrReconcile()
{
@@ -231,6 +234,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// pattern as row 67, with the trigger named so an operator can tell a sweep heal from a
/// startup-reconcile heal.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SweepStartedInstance_EmitsAWarningSiteEvent_NamingTheSweepTrigger()
{
@@ -259,6 +263,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// A DISABLED instance legitimately has a config row and no running actor. The sweep must
/// never start it — that would silently undo an operator's disable.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_NeverStarts_ADisabledInstance()
{
@@ -280,6 +285,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// that re-fired every tick would be a restart loop plus an unbounded stream of Warning site
/// events.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_StartsTheInstanceExactlyOnce_AcrossManySweeps()
{
@@ -306,6 +312,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// A sweep that finds nothing wrong must be completely silent — no site events at all. This
/// runs on every tick of every healthy site, forever.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_IsSilent_WhenThereIsNothingToHeal()
{
@@ -327,6 +334,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// site that wants the pre-fix behaviour keeps it (and the option's disable path is proven,
/// not merely validated).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_DoesNothing_WhenTheIntervalIsZero()
{
@@ -355,6 +363,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// nothing.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_DefersToAnInFlightRedeploy()
{
@@ -393,17 +402,18 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
// ── Row 72: a running actor serving a revision the store has replaced ──
/// <summary>
/// THE row-72 reproduction. An instance is deployed and running at <c>rev-1</c>; the durable
/// row is then refreshed to <c>rev-2</c> out of band — what
/// THE reproduction of the stale-revision bug. An instance is deployed and running at its first
/// revision; the durable row is then refreshed to the second revision out of band — what
/// <c>StoreDeployedConfigIfNewerAsync</c> does when the startup reconcile finds a STALE
/// config, and what a replicated deploy does on the standby.
///
/// <para>
/// <b>Fails before the fix</b>: the actor keeps serving <c>rev-1</c>'s config indefinitely
/// <b>Fails before the fix</b>: the actor keeps serving the first revision's config indefinitely
/// (the snapshot still shows the old attribute value) and nothing anywhere says the stored
/// and running revisions differ.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_RestartsInstance_ServingAStaleRevision()
{
@@ -444,6 +454,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// The stale-revision restart must not fire when the stored and running revisions agree —
/// otherwise every healthy instance on the site would be restarted once a minute.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_DoesNotRestart_WhenStoredAndRunningRevisionsMatch()
{
@@ -470,6 +481,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// site events for a config that can never apply, and the running instance is unharmed —
/// the gate returns before anything is stopped, so it keeps serving the old config.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_StaleRestartRejectedByTheCompileGate_IsAnnouncedOnceAndNotRetried()
{
@@ -541,12 +553,12 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
StoreGenerationAtRead: generationAtRead);
/// <summary>
/// <b>Deferred register row 101, the row-72 leg.</b> A sweep read dispatched before a deploy
/// <b>Deferred register row 101, the stale-revision leg.</b> A sweep read dispatched before a deploy
/// persisted its new row can land after that deploy has completed AND after the deploy
/// pipeline released the instance — <see cref="DeploymentManagerActor"/>'s
/// <c>InstanceIsOwnedByADeployPipeline</c> only covers a deploy still in flight, so it is no
/// defence here. The snapshot then carries the revision the deploy just REPLACED while the
/// running actor carries the new one, which is bit-for-bit the divergence the row-72 repair
/// running actor carries the new one, which is bit-for-bit the divergence the stale-revision repair
/// looks for — in the wrong direction.
///
/// <para>
@@ -556,6 +568,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// claim marks the OLD revision as attempted, so the instance simply stays reverted.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleSweepSnapshot_CarryingASupersededRevision_DoesNotRestartBackward()
{
@@ -586,7 +599,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
}
/// <summary>
/// The row-101 guard must not blunt the row-72 repair it sits in front of: the SAME message
/// The stale-snapshot guard must not blunt the stale-revision repair it sits in front of: the SAME message
/// stamped with the CURRENT generation is a genuine answer and must still converge the
/// runtime on the stored revision.
///
@@ -597,6 +610,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// meaning, which is the whole point of the guard.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FreshSweepSnapshot_CarryingANewerRevision_StillRestartsTheInstance()
{
@@ -630,7 +644,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
}
/// <summary>
/// <b>Deferred register row 101, the row-78 leg.</b> A sweep read dispatched before a delete
/// <b>Deferred register row 101, the missing-start leg.</b> A sweep read dispatched before a delete
/// committed still shows the instance's row — enabled, with a config — while the actor has
/// already stopped it and dropped its bookkeeping. The delete specifically clears
/// <c>_sweepAttemptedRevisions</c>, so the once-per-revision claim is freshly available and
@@ -641,6 +655,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// re-adds it to the deployed-name set, and announces the resurrection as a self-heal.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleSweepSnapshot_ShowingADeletedInstance_DoesNotResurrectIt()
{
@@ -667,9 +682,10 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
/// <summary>
/// The mirror of the resurrection test: the same enabled-config-with-no-actor snapshot,
/// stamped with the CURRENT generation, must still perform the row-78 start. Nothing has
/// stamped with the CURRENT generation, must still perform the missing-start heal. Nothing has
/// mutated the store through this actor, so <c>0</c> is current here.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FreshSweepSnapshot_ShowingAStoredEnabledInstance_StillStartsIt()
{
@@ -700,6 +716,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
///
/// <para><b>Fails before the fix</b>: one read, and the pre-mutation snapshot is applied.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleBootSnapshot_IsReReadRatherThanApplied()
{
@@ -17,7 +17,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start,
/// NativeAlarmActor mirrors a source's native alarms — subscribe on start,
/// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence.
/// </summary>
public class NativeAlarmActorTests : TestKit, IDisposable
@@ -26,6 +26,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
private readonly SiteStorageService _storage;
private readonly SiteRuntimeOptions _options = new();
/// <summary>Initializes the test class and its test doubles.</summary>
public NativeAlarmActorTests()
{
// SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the
@@ -56,6 +57,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Source(), "inst", instanceActor, dclManager, _storage, options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
/// <summary>Verifies that stopping the actor releases the alarm subscription with the owner identity.</summary>
[Fact]
public void Stop_ReleasesAlarmSubscriptionWithOwnerIdentity()
{
@@ -68,6 +70,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal(actor, dcl.LastSender);
}
/// <summary>Verifies that a duplicate snapshot end does not clear a committed alarm.</summary>
[Fact]
public void DuplicateSnapshotEnd_DoesNotClearCommittedAlarm()
{
@@ -99,6 +102,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that the actor sends a subscribe request for the source binding on start.</summary>
[Fact]
public void SubscribeOnStart_SendsRequestForSourceBinding()
{
@@ -111,6 +115,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal("ns=2;s=T01", req.SourceReference);
}
/// <summary>Verifies that a raise emits an enriched AlarmStateChanged.</summary>
[Fact]
public void Raise_EmitsEnrichedAlarmStateChanged()
{
@@ -130,10 +135,11 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal(800, emitted.Condition.Severity);
}
/// <summary>Verifies that a raise stamps the native source canonical name from the configured binding.</summary>
[Fact]
public void Raise_StampsNativeSourceCanonicalName_FromConfiguredBinding()
{
// DV-1: every emitted condition carries the canonical name of the source
// Every emitted condition carries the canonical name of the source
// BINDING it belongs to, so the DebugView can nest live native conditions
// under their configured binding node. It must equal the binding's
// CanonicalName used to construct the actor (Source().CanonicalName).
@@ -151,6 +157,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal("Pressure", emitted.NativeSourceCanonicalName);
}
/// <summary>Verifies that a snapshot completing with a missing prior alarm emits a return to normal.</summary>
[Fact]
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
{
@@ -174,6 +181,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmState.Normal, cleared.State);
}
/// <summary>Verifies that an older transition is ignored.</summary>
[Fact]
public void OlderTransition_IsIgnored()
{
@@ -196,7 +204,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
// ── PLAN-03 Task 20 (P4): coalesced batched persistence ─────────────────
// ── Coalesced batched persistence ─────────────────
private IActorRef SpawnWithFlush(IActorRef instanceActor, IActorRef dclManager, TimeSpan flushInterval) =>
ActorOf(Props.Create(() => new NativeAlarmActor(
@@ -208,6 +216,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
sourceRef, AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity), time)));
/// <summary>Verifies that an alarm storm coalesces persistence so the latest write per source reference wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmStorm_CoalescesPersistence_LatestPerSourceRefWins()
{
@@ -226,12 +236,14 @@ public class NativeAlarmActorTests : TestKit, IDisposable
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Equal(5, rows.Count);
// The LAST write for ref-4 carries severity 49 (i = 49, 49 % 5 == 4).
// The LAST write for source ref 4 carries severity 49 (i = 49, 49 % 5 == 4).
Assert.Contains(rows, r => r.ConditionJson.Contains("49"));
}
// ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ────────
// ── Rehydration restores display metadata ────────
/// <summary>Verifies that rehydration restores the display metadata.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rehydration_RestoresDisplayMetadata()
{
@@ -266,8 +278,9 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal("Tank overflow", emitted.Message);
}
// ── M1.5: site event log `alarm` category ──────────────────────────────
// ── Site event log `alarm` category ──────────────────────────────
/// <summary>Verifies that a raise emits an alarm site event.</summary>
[Fact]
public void Raise_EmitsAlarmSiteEvent()
{
@@ -293,6 +306,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that a clear emits an informational alarm site event.</summary>
[Fact]
public void Clear_EmitsInfoAlarmSiteEvent()
{
@@ -323,6 +337,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that rehydration does not emit a site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rehydration_DoesNotEmitSiteEvent()
{
@@ -352,6 +368,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that a snapshot swap with an existing active condition does not re-emit.</summary>
[Fact]
public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit()
{
@@ -386,6 +403,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Single(siteLog.OfType("alarm"));
}
/// <summary>Verifies that an acknowledge emits an informational alarm site event mentioning acknowledged.</summary>
[Fact]
public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged()
{
@@ -418,17 +436,18 @@ public class NativeAlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
// ── SiteRuntime-028: cap eviction emits a return-to-normal for an active drop ──
// ── Cap eviction emits a return-to-normal for an active drop ──
/// <summary>Verifies that enforcing the cap evicts the oldest active condition and emits a return to normal and a drop signal.</summary>
[Fact]
public void EnforceCap_EvictsActiveOldestCondition_EmitsReturnToNormalAndDropSignal()
{
// SiteRuntime-028: a cap eviction that drops a still-Active condition without a
// A cap eviction that drops a still-Active condition without a
// return-to-normal leaves the Instance Actor (and central's stream / the
// operator Alarm Summary) showing a phantom Active alarm forever. With cap=1,
// raising a second condition evicts the oldest (still Active) one — which must
// produce a Normal AlarmStateChanged for the evicted SourceReference, plus the
// SiteRuntime-027 NativeAlarmDropped so the parent evicts its stale key.
// NativeAlarmDropped so the parent evicts its stale key.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var options = new SiteRuntimeOptions { MirroredAlarmCapPerSource = 1 };
@@ -456,12 +475,13 @@ public class NativeAlarmActorTests : TestKit, IDisposable
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "OLD.Hi");
}
// ── SiteRuntime-027: terminal retention drop signals the parent to evict its key ──
// ── Terminal retention drop signals the parent to evict its key ──
/// <summary>Verifies that a retention drop of a resolved condition emits a return to normal and then a drop signal.</summary>
[Fact]
public void RetentionDrop_ResolvedCondition_EmitsReturnToNormalThenDropSignal()
{
// SiteRuntime-027: when a native condition resolves (inactive AND acknowledged)
// When a native condition resolves (inactive AND acknowledged)
// it drops out of the mirror. The Instance Actor must be told (NativeAlarmDropped)
// so its _latestAlarmEvents map does not retain a stale (Normal) entry forever —
// otherwise a source that mints a fresh SourceReference per occurrence leaks one
@@ -486,6 +506,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "T01.Hi");
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actor system down FIRST: in-flight alarm actors still hold the
@@ -500,6 +521,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
// ── MES alarm-status API §6.4: AckTime through the mirror ──────────────
/// <summary>Verifies that the emitted state carries the adapter's ack time verbatim.</summary>
[Fact]
public void Emit_CarriesTheAdaptersAckTimeVerbatim()
{
@@ -527,6 +549,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.True(acked.Condition.Acknowledged);
}
/// <summary>Verifies that rehydration restores the ack time so it survives failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rehydration_RestoresAckTime_SoItSurvivesFailover()
{
@@ -561,6 +585,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal(ackedAt, emitted.AckTime);
}
/// <summary>Verifies that rehydrating a row from before ack times existed leaves the ack time null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rehydration_OfAPreAckTimeRow_LeavesAckTimeNull()
{
@@ -591,6 +617,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Null(emitted.AckTime); // …with no ack time invented
}
/// <summary>Verifies that a lost subscribe response causes the subscribe to be resent.</summary>
[Fact]
public void LostSubscribeResponse_ResendsSubscribe()
{
@@ -604,6 +631,9 @@ public class NativeAlarmActorTests : TestKit, IDisposable
// Swallow the response entirely — the actor must still re-subscribe.
dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that a late rehydration cannot resurrect a resolved or snapshot-removed alarm.</summary>
/// <param name="snapshot">Whether the scenario removes the alarm through a snapshot rather than resolving it.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -627,6 +657,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), e => e.State == AlarmState.Active);
}
/// <summary>Verifies that a stale success cannot cancel the current subscribe retry.</summary>
[Fact]
public void SR3_StaleSuccessCannotCancelCurrentSubscribeRetry()
{
@@ -641,6 +672,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a connection-wide restart discards an interrupted MX snapshot.</summary>
[Fact]
public void DC5_ConnectionWideRestartDiscardsInterruptedMxSnapshot()
{
@@ -654,6 +686,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that a live clear during a snapshot cannot be resurrected by the snapshot end.</summary>
[Fact]
public void DC5_LiveClearDuringSnapshotCannotBeResurrectedBySnapshotEnd()
{
@@ -47,6 +47,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with a fresh local database and the actor-system fixtures.</summary>
public ReconcileStartsInstanceTests()
{
_localDb = TestLocalDb.CreateTemp("reconcile-start-test");
@@ -59,6 +60,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
@@ -79,13 +81,13 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// would still pass, while proving the BOOT LOAD works rather than the startup-reconcile path
/// it claims to cover. A sleep only makes that race unlikely; on a loaded machine the load can
/// still be in flight when it expires, and the test silently changes what it proves instead of
/// failing. The row-78 fix session hit exactly this trap and closed it in
/// <c>InstanceSweepSelfHealTests</c>; this is the same gate, applied to row 67's suite.
/// failing. An earlier fix session hit exactly this trap and closed it in
/// <c>InstanceSweepSelfHealTests</c>; this is the same gate, applied to this suite.
/// </para>
///
/// <para>
/// The sweep is disabled (<c>InstanceSweepIntervalSeconds = 0</c>) so these tests keep proving
/// the row-67 <see cref="EnsureInstanceStartedCommand"/> path specifically — with the sweep
/// the <see cref="EnsureInstanceStartedCommand"/> path specifically — with the sweep
/// armed, its periodic self-heal would independently start the same instances and the suite
/// would no longer distinguish the two mechanisms.
/// </para>
@@ -165,7 +167,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
// ── The reproduction ──
/// <summary>
/// THE row-67 reproduction, end to end over one shared storage file: the node boots with no
/// THE reproduction, end to end over one shared storage file: the node boots with no
/// config for an enabled instance (so the Deployment Manager's startup load starts nothing),
/// then the startup reconcile pass fetches and stores that config. The instance must be
/// RUNNING afterwards — not merely present in SQLite waiting for a second restart.
@@ -176,6 +178,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// Instance Actor.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartupReconcile_StoringConfigForEnabledInstance_StartsTheInstanceActor()
{
@@ -221,6 +224,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// <c>deployment</c> site event naming the instance, so an operator can see that a node
/// booted without a config and self-repaired.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileStartedInstance_EmitsASiteEvent()
{
@@ -319,6 +323,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// The command is idempotent: both site nodes' reconcile actors legitimately raise it for
/// the same instance, and a repeat must not disturb (or replace) the running actor.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureInstanceStarted_IsIdempotent_WhenAlreadyRunning()
{
@@ -353,6 +358,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// stopped even if an ensure-started request arrives for it (e.g. a disable landed between
/// the reconcile fetch and the start request).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureInstanceStarted_DisabledInstance_StaysStopped()
{
@@ -373,6 +379,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// no-op — the normal case on an unreplicated site whose peer node healed its own file.
/// The Deployment Manager must not crash or invent an instance.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureInstanceStarted_UnknownInstance_IsAQuietNoOp()
{
@@ -404,11 +411,12 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
///
/// <para>
/// This test holds the read open, commits a disable behind it, then releases it — the
/// interleaving the fix's store-write-generation stamp exists to reject (arch-review
/// 2026-08-19 P1-1). <b>Fails before the fix</b>: the stale enabled snapshot passes every
/// interleaving the fix's store-write-generation stamp exists to reject.
/// <b>Fails before the fix</b>: the stale enabled snapshot passes every
/// guard and the Instance Actor is created for a disabled instance.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureInstanceStarted_ResultLandingAfterADisable_IsDiscarded()
{
@@ -451,6 +459,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
///
/// <para><b>Fails before the fix</b> at the final assert.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureInstanceStarted_ResultLandingAfterADelete_IsDiscarded()
{
@@ -485,8 +494,9 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// <summary>
/// The complement of the two tests above: with no mutation in the read's window the heal
/// still starts the instance, so the generation check rejects superseded snapshots without
/// disabling the row-67 repair itself.
/// disabling the repair itself.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnsureInstanceStarted_ResultLandingWithNoInterveningWrite_StillStarts()
{
@@ -523,7 +533,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
/// Reads real storage every time and only parks AFTER the read, so the returned snapshot is
/// exactly what production would have seen at that instant. Inert until <see cref="Arm"/>
/// is called, which keeps the Deployment Manager's boot load — a separate seam
/// (<c>_configLoader</c>, register P1-4) untouched by this gate — unblocked.
/// (<c>_configLoader</c>) untouched by this gate — unblocked.
/// </para>
/// </summary>
private sealed class GatedConfigLoader(SiteStorageService storage)
@@ -532,13 +542,20 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
private readonly TaskCompletionSource _parked =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Arms the gate so the next load parks until released.</summary>
public void Arm() => _release = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Releases the parked load so it can complete.</summary>
public void Release() => _release!.TrySetResult();
/// <summary>Waits until a load has parked at the gate.</summary>
/// <returns>A task that completes once a load is parked at the gate.</returns>
public Task WaitUntilParkedAsync() => _parked.Task.WaitAsync(TimeSpan.FromSeconds(10));
/// <summary>Reads the deployed config from storage and then parks at the gate if armed.</summary>
/// <param name="instanceName">The unique name of the instance to load.</param>
/// <returns>The stored deployed instance, or null when none exists.</returns>
public async Task<DeployedInstance?> LoadAsync(string instanceName)
{
var config = await storage.GetDeployedConfigAsync(instanceName);
@@ -559,10 +576,14 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
private sealed class FakeConfigFetcher : IDeploymentConfigFetcher
{
private readonly Func<string, Task<string>> _behavior;
/// <summary>Gets the recorded fetch calls (base URL, deployment id, token).</summary>
public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new();
/// <summary>Initializes the fake with the per-deployment-id behavior to run on each fetch.</summary>
/// <param name="behavior">Maps a deployment id to the fetch result.</param>
public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior;
/// <inheritdoc />
public async Task<string> FetchAsync(
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
{
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for arch-review 2026-08-19 P1-2 — <b>"static overrides are reset on
/// Regression tests for arch-review 2026-08-19 — <b>"static overrides are reset on
/// redeployment" was racy in both directions</b>.
///
/// <para>
@@ -45,6 +45,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site storage, compilation service and shared script library for each test.</summary>
public RedeployStaticOverrideResetTests()
{
_localDb = TestLocalDb.CreateTemp("redeploy-override-test");
@@ -57,6 +58,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
@@ -135,6 +137,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
/// <see cref="RedeployResetGate_HoldsTheOverrideLoadUntilTheResetHasCommitted"/>.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Redeploy_DoesNotApplyPreRedeployOverridesToTheNewActor()
{
@@ -179,6 +182,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
/// after it is released proves the read is ordered behind the reset rather than merely
/// losing a race to it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RedeployResetGate_HoldsTheOverrideLoadUntilTheResetHasCommitted()
{
@@ -223,6 +227,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
// Leg 2: final persistence is acknowledged independently of termination.
// The manager waits for both before resetting state; a standalone actor still
// persists its final dirty batch asynchronously after stopping.
/// <summary>Verifies that stopping an Instance Actor completes its final override flush asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceActorStop_CompletesFinalOverrideFlushAsynchronously()
{
@@ -264,6 +270,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
/// the clear and durably resurrect the predecessor's values — invisible until the next
/// restart loaded them.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Redeploy_LateFlushFromTheStoppedActor_CannotResurrectClearedOverrides()
{
@@ -297,6 +304,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
/// up on its config defaults, and an override written after it must persist normally. Pins
/// that the reset gate did not turn the ordinary deploy path into a no-op.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FirstDeploy_StillLoadsAndPersistsStaticOverridesNormally()
{
@@ -318,6 +326,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
"corr-get", instanceName, "Temperature", DateTimeOffset.UtcNow));
Assert.Equal("123.4", ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5)).Value?.ToString());
}
/// <summary>Verifies that stopping an actor does not block the dispatcher while the SQLite writer is busy.</summary>
[Fact]
public void SR5_StopDoesNotBlockDispatcherWhileSqliteWriterIsBusy()
{
@@ -337,6 +346,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
ExpectTerminated(actor, TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a redeploy waits for the flush acknowledgement after the predecessor terminates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR5_RedeployWaitsForFlushAcknowledgementAfterTermination()
{
@@ -360,6 +371,9 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
Assert.Empty(await _storage.GetStaticOverridesAsync(name));
}
/// <summary>Verifies that cancelling a queued redeploy stops its predecessor.</summary>
/// <param name="delete">Whether the queued operations are deletes rather than redeploys.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -392,6 +406,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
transaction.Commit();
}
/// <summary>Verifies that a delete waits for the final flush and rejects an overlapping start.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR5_DeleteWaitsForFinalFlushAndRejectsAnOverlappingStart()
{
@@ -15,10 +15,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP-15: Script Actor and Script Execution Actor tests.
/// WP-20: Recursion limit tests.
/// WP-22: Tell vs Ask convention tests.
/// WP-32: Script error handling tests.
/// Script Actor and Script Execution Actor tests, including recursion limit,
/// Tell vs Ask convention and script error handling coverage.
/// </summary>
public class ScriptActorTests : TestKit, IDisposable
{
@@ -26,7 +24,7 @@ public class ScriptActorTests : TestKit, IDisposable
private readonly SiteRuntimeOptions _options;
private readonly ScriptCompilationService _compilationService;
// Gitea #16/#18: every ScriptActor built here runs its trigger-expression
// Every ScriptActor built here runs its trigger-expression
// evaluation and spawned script bodies on THIS class's own scheduler, never the
// process-wide singleton. That is what makes the faulted-task test below
// un-leakable across the assembly: any worker a test strands (or the 30 s bounded
@@ -34,6 +32,7 @@ public class ScriptActorTests : TestKit, IDisposable
// teardown, so it can never starve unrelated test classes' script executions.
private readonly ScriptExecutionScheduler _scheduler;
/// <summary>Initializes the test with an isolated script scheduler, shared script library and compilation service.</summary>
public ScriptActorTests()
{
_compilationService = new ScriptCompilationService(
@@ -48,6 +47,7 @@ public class ScriptActorTests : TestKit, IDisposable
_scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -65,6 +65,7 @@ public class ScriptActorTests : TestKit, IDisposable
return script;
}
/// <summary>Verifies that calling a script returns its result.</summary>
[Fact]
public void ScriptActor_CallScript_ReturnsResult()
{
@@ -91,7 +92,7 @@ public class ScriptActorTests : TestKit, IDisposable
null, // serviceProvider
_scheduler)));
// Ask pattern (WP-22) for CallScript
// Ask pattern for CallScript
scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
@@ -99,6 +100,7 @@ public class ScriptActorTests : TestKit, IDisposable
Assert.Equal(42, result.ReturnValue);
}
/// <summary>Verifies that calling a script with parameters passes them to the script.</summary>
[Fact]
public void ScriptActor_CallScript_WithParameters_Works()
{
@@ -133,6 +135,7 @@ public class ScriptActorTests : TestKit, IDisposable
Assert.Equal(7, result.ReturnValue);
}
/// <summary>Verifies that a script actor with no compiled script replies with an error.</summary>
[Fact]
public void ScriptActor_NullCompiledScript_ReturnsError()
{
@@ -165,6 +168,7 @@ public class ScriptActorTests : TestKit, IDisposable
Assert.Contains("not compiled", result.ErrorMessage);
}
/// <summary>Verifies that a value-change trigger spawns a script execution.</summary>
[Fact]
public void ScriptActor_ValueChangeTrigger_SpawnsExecution()
{
@@ -202,6 +206,7 @@ public class ScriptActorTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that a trigger arriving within the minimum time between runs is skipped.</summary>
[Fact]
public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon()
{
@@ -243,6 +248,7 @@ public class ScriptActorTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that a script failure does not disable the script actor.</summary>
[Fact]
public void ScriptActor_WP32_ScriptFailure_DoesNotDisable()
{
@@ -342,6 +348,7 @@ public class ScriptActorTests : TestKit, IDisposable
private AttributeValueChanged Change(string attribute, object? value) =>
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
/// <summary>Verifies that the non-numeric fallback comparison of a conditional trigger is culture-invariant.</summary>
[Fact]
public void ConditionalTrigger_NonNumericFallback_IsCultureInvariant()
{
@@ -393,6 +400,7 @@ public class ScriptActorTests : TestKit, IDisposable
return s;
}
/// <summary>Verifies that a faulted expression evaluation clears the in-flight flag and drains the pending evaluation.</summary>
[Fact]
public void ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation()
{
@@ -406,10 +414,10 @@ public class ScriptActorTests : TestKit, IDisposable
actor.Tell(Change("A", "1")); // eval starts off-thread and BLOCKS → _evalInFlight = true
AwaitAssert(() => Assert.Equal(1, EvalGate.Entries), TimeSpan.FromSeconds(10));
// WP3.1: the blocked evaluation no longer occupies a script-execution worker at
// The blocked evaluation no longer occupies a script-execution worker at
// all — it runs on the shared thread pool behind TriggerEvalGate, which is the
// whole point of finding #4. The blocking pool must be completely idle here; the
// pre-WP3.1 assertion was the opposite (BusyThreadCount == 1).
// whole point of moving it off the blocking pool. The blocking pool must be completely idle here; the
// earlier assertion was the opposite (BusyThreadCount == 1).
Assert.Equal(0, _scheduler.BusyThreadCount);
Assert.Equal(0, _scheduler.QueueDepth);
@@ -433,14 +441,15 @@ public class ScriptActorTests : TestKit, IDisposable
}
}
/// <summary>Verifies that an expression trigger is evaluated off the blocking script pool and still fires.</summary>
[Fact]
public void ExpressionTrigger_EvaluatesOffTheBlockingScriptPool_AndStillFires()
{
// WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
// Retarget of the former P1 assertion, whose sense is deliberately INVERTED.
//
// P1 moved trigger-expression evaluation off the actor's dispatcher thread and onto
// the dedicated script-execution scheduler, and this test asserted exactly that
// ("evaluated on a script-execution-* thread"). WP3.1 (finding #4) proved that
// ("evaluated on a script-execution-* thread"). Later work proved that
// destination wrong: sharing the blocking pool meant N blocked script bodies stalled
// every Expression trigger on the node indefinitely. Evaluations are non-blocking by
// construction, so they now run as plain async work on the shared .NET thread pool
@@ -463,6 +472,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off the blocking pool
}
/// <summary>Verifies that a while-true conditional trigger fires on the edge and then re-fires while the condition holds.</summary>
[Fact]
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
{
@@ -482,6 +492,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
}
/// <summary>Verifies that a while-true conditional trigger stops re-firing when the condition goes false.</summary>
[Fact]
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
{
@@ -501,6 +512,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
}
/// <summary>Verifies that a while-true conditional trigger re-arms after the condition goes false and then true again.</summary>
[Fact]
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
{
@@ -520,6 +532,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that a while-true conditional trigger without a minimum time between runs fires only once.</summary>
[Fact]
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
{
@@ -535,6 +548,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
}
/// <summary>Verifies that an on-true conditional trigger fires on each change while true, without a timer.</summary>
[Fact]
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
{
@@ -554,6 +568,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
}
/// <summary>Verifies that a while-true expression trigger re-fires while the expression holds.</summary>
[Fact]
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
{
@@ -574,6 +589,7 @@ public class ScriptActorTests : TestKit, IDisposable
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that an on-true expression trigger fires once per false-to-true edge.</summary>
[Fact]
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
{
@@ -618,7 +634,7 @@ public static class EvalGate
/// Resets the gate to a fresh, permit-free state and zeroes the entry counter.
/// Called at the start of the test so counts never carry across runs — the old
/// static leaked <c>_entries</c> from whatever compiled an expression before it
/// (Gitea #16).
/// .
/// </summary>
public static void Reset()
{
@@ -630,9 +646,10 @@ public static class EvalGate
/// Counts the entry, then blocks the calling script-scheduler thread until a permit
/// is released. The wait is BOUNDED (30 s): even if a test releases too few permits,
/// a stranded evaluation frees its worker instead of parking it for the rest of the
/// process — the leak mechanism behind Gitea #16. Returns false so a trigger
/// process — the leak mechanism behind stranded workers. Returns false so a trigger
/// expression built on it evaluates to "not fired".
/// </summary>
/// <returns>Always <see langword="false"/>.</returns>
public static bool Block()
{
Interlocked.Increment(ref _entries);
@@ -27,12 +27,18 @@ public class ScriptActorTraceTests : TestKit, IDisposable
{
private sealed class CapturingAuditWriter : IAuditWriter
{
/// <summary>The audit events captured so far.</summary>
public List<AuditEvent> Events { get; } = new();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events) Events.Add(evt);
return Task.CompletedTask;
}
/// <summary>Polls the captured events until one with the given action appears or the timeout elapses.</summary>
/// <param name="action">The audit action to wait for.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The first matching event, or <see langword="null"/> if none arrived in time.</returns>
public AuditEvent? WaitForKind(string action, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -55,6 +61,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
private readonly CapturingAuditWriter _writer = new();
private readonly IServiceProvider _services;
/// <summary>Creates the shared script library, options, scheduler and service provider used by every test.</summary>
public ScriptActorTraceTests()
{
_sharedLibrary = new SharedScriptLibrary(
@@ -67,6 +74,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
_services = sc.BuildServiceProvider();
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -91,6 +99,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
NullLogger<ScriptActor>.Instance, null, null, null, _services, _scheduler)));
}
/// <summary>Verifies that a traced call replies with the execution ID and a ScriptRun trace event.</summary>
[Fact]
public void Call_WithTrace_ReplyCarriesExecutionId_AndAScriptRunEvent()
{
@@ -117,6 +126,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
Assert.NotNull(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(5)));
}
/// <summary>Verifies that a traced call whose script fails returns a failed ScriptRun trace event.</summary>
[Fact]
public void Call_WithTrace_FailedRun_ReturnsFailedScriptRunEvent()
{
@@ -132,6 +142,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
Assert.Contains("kaboom", row.ErrorMessage);
}
/// <summary>Verifies that an untraced call replies with no trace events but still carries the execution ID.</summary>
[Fact]
public void Call_WithoutTrace_ReplyHasNoTraceEvents_ButStillCarriesExecutionId()
{
@@ -147,6 +158,9 @@ public class ScriptActorTraceTests : TestKit, IDisposable
Assert.Empty(_writer.Events);
}
/// <summary>Verifies that a traced value change makes the triggered run a child of the source execution ID.</summary>
/// <param name="inferred">Whether the change is an inferred device update rather than a direct write.</param>
/// <param name="triggerSource">The trigger source label stamped on the change.</param>
[Theory]
[InlineData(false, "value-change")]
[InlineData(true, "inferred-device-update")]
@@ -170,6 +184,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
Assert.Equal(triggerSource, extra.TriggerSource);
}
/// <summary>Verifies that an untraced value change leaves the triggered run as a root and emits no trace events.</summary>
[Fact]
public void ValueChangeTrigger_UntracedChange_RunStaysRoot_AndEmitsNothing()
{
@@ -12,10 +12,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 3 — a script's execution deadline is armed when the run is ENQUEUED, not
/// A script's execution deadline is armed when the run is ENQUEUED, not
/// when it is dequeued.
///
/// <para>Before WP3.1 the deadline <see cref="CancellationTokenSource"/> was constructed inside
/// <para>Previously the deadline <see cref="CancellationTokenSource"/> was constructed inside
/// the queued body, so a run that spent ten minutes waiting behind blocked scripts still got a
/// fresh full 30 s budget when it finally started — and then ran work whose triggering
/// condition was long stale. Now queue wait consumes the run's own budget, and a body that
@@ -26,6 +26,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
/// <summary>Initializes the test with an isolated script execution scheduler.</summary>
public ScriptDeadlineAtEnqueueTests()
{
var compilationService = new ScriptCompilationService(
@@ -36,6 +37,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
DeadlineHooks.SecondScriptRan = false;
}
/// <inheritdoc />
void IDisposable.Dispose()
{
DeadlineHooks.Gate.Release(8);
@@ -64,6 +66,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
NullLogger<ScriptActor>.Instance, null, null, null, serviceProvider, scheduler, null)));
}
/// <summary>Verifies that a run that dequeues past its deadline is shed without executing its body.</summary>
[Fact]
public void ARunThatDequeuesPastItsDeadline_IsShedWithoutExecutingItsBody()
{
@@ -86,7 +89,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
{
ScriptExecutionTimeoutSeconds = 1,
StuckScriptGraceMs = 1000,
// WP3.2 defaults per-run Started/Completed events OFF, which would make the
// The defaults leave per-run Started/Completed events OFF, which would make the
// "no started event" assertion below vacuously true whether the body ran or not.
// Opt back in so that assertion actually discriminates: a body that reached the
// run loop WOULD emit "started" here, and its absence is therefore evidence.
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <c>ScriptRuntimeContext.CallScript</c> — unreachable.</para>
///
/// <para>The cap now applies to <c>callDepth == 0</c> launches only: trigger-driven runs and
/// depth-0 Ask calls, i.e. genuinely NEW work. Both halves are pinned below.</para>
/// depth 0 Ask calls, i.e. genuinely NEW work. Both halves are pinned below.</para>
/// </summary>
public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
{
@@ -43,6 +43,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
RecursionHooks.Reset();
}
/// <inheritdoc />
void IDisposable.Dispose()
{
RecursionHooks.Gate.Release(64);
@@ -142,8 +143,8 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
}
/// <summary>
/// The exemption is scoped to nesting: with the cap already full of depth-0 runs, another
/// depth-0 request is still shed, while a nested (<c>callDepth &gt; 0</c>) request is
/// The exemption is scoped to nesting: with the cap already full of depth 0 runs, another
/// depth 0 request is still shed, while a nested (<c>callDepth &gt; 0</c>) request is
/// launched. This is the discriminating pin — a blanket "skip the cap" would fail the first
/// half, and the pre-fix behaviour fails the second.
/// </summary>
@@ -164,7 +165,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
null, null, health, new SingleServiceProvider(siteLog), _scheduler, null)),
"cap-" + Guid.NewGuid().ToString("N"));
// Fill the cap with four depth-0 runs, all blocked in their bodies.
// Fill the cap with four depth 0 runs, all blocked in their bodies.
for (var i = 0; i < 4; i++)
actor.Tell(new ScriptCallRequest("Hot", null, 0, $"corr-{i}"), ActorRefs.NoSender);
@@ -195,7 +196,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
// It is running, not answered: no shed reply reached the nested caller.
nested.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// Exactly the one depth-0 refusal was counted.
// Exactly the one depth 0 refusal was counted.
Assert.Equal(1, health.CollectReport("site-1").ScriptRunShedCount);
}
@@ -18,10 +18,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 supervision-parity set (design memo §4, seven pins) — the reworked successor to
/// Supervision-parity set (design memo §4, seven pins) — the reworked successor to
/// <c>ExecutionActorTests</c>.
///
/// <para>WP3.1 eliminated the short-lived <c>ScriptExecutionActor</c> and
/// <para>The refactor eliminated the short-lived <c>ScriptExecutionActor</c> and
/// <c>AlarmExecutionActor</c>: neither had a <c>Receive</c> handler, a <c>PostStop</c>, or any
/// state, and neither's <c>IActorRef</c> was ever a message target — the entire lifecycle lived
/// in a detached task. Runs are now launched directly by the coordinator via
@@ -35,9 +35,10 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptCompilationService _compilationService;
/// <summary>Own pool per test class (#18 seam), so a wedged body cannot strand the process-wide one.</summary>
/// <summary>Own pool per test class, so a wedged body cannot strand the process-wide one.</summary>
private readonly ScriptExecutionScheduler _scheduler = new(4);
/// <summary>Initializes the test with a compilation service and shared script library.</summary>
public ScriptRunLauncherParityTests()
{
_compilationService = new ScriptCompilationService(
@@ -46,6 +47,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
@@ -72,7 +74,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = timeoutSeconds,
StuckScriptGraceMs = graceMs,
// WP3.2: per-run Started/Completed Info events default OFF; tests that pin the
// Per-run Started/Completed Info events default OFF; tests that pin the
// legacy per-run event shape opt back in explicitly via this flag.
PerRunScriptEvents = perRunScriptEvents
};
@@ -104,6 +106,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
// ── Pin 1: throwing body — coordinator survives, everything is reported ───────
/// <summary>Verifies that a throwing script body leaves the script actor alive and reports everything.</summary>
[Fact]
public void ThrowingScriptBody_LeavesScriptActorAliveAndReportsEverything()
{
@@ -150,7 +153,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
/// The only failure the removed per-run child could surface was a constructor throw —
/// e.g. queueing onto a disposed <see cref="ScriptExecutionScheduler"/>. The old
/// <c>OneForOneStrategy</c> logged it and stopped the child, leaving the Ask caller to
/// hang with no reply at all. WP3.1 folds that into a launch-path catch that replies, an
/// hang with no reply at all. The launcher folds that into a launch-path catch that replies, an
/// intentional improvement pinned here so it is explicit rather than accidental.
/// </summary>
[Fact]
@@ -182,6 +185,9 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
// ── Pin 3: exactly one DI scope per run, disposed on every path ───────────────
/// <summary>Verifies that each run creates one DI scope and disposes it exactly once.</summary>
/// <param name="code">The script source to run.</param>
/// <param name="timeoutSeconds">The script timeout in seconds.</param>
[Theory]
[InlineData("return 1;", 30)] // success
[InlineData("throw new InvalidOperationException(\"boom\");", 30)] // failure
@@ -240,6 +246,9 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
// ── Pin 5: timeout resolution parity (perScript ?? global, <= 0 => global) ────
/// <summary>Verifies that timeout resolution matches the behavior of the removed execution actor.</summary>
/// <param name="globalSeconds">The global script timeout in seconds.</param>
/// <param name="perScriptSeconds">The per-script timeout in seconds, or null to use the global timeout.</param>
[Theory]
[InlineData(300, 1)] // per-script override wins over a much longer global
[InlineData(1, null)] // null per-script falls back to the global
@@ -377,6 +386,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
// ── Retargeted from ExecutionActorTests: success path + operational events ────
/// <summary>Verifies that a successful run replies with the return value.</summary>
[Fact]
public void SuccessfulRun_RepliesWithTheReturnValue()
{
@@ -391,11 +401,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
Assert.Equal(42, result.ReturnValue);
}
/// <summary>Verifies that a successful run emits Started then Completed info events.</summary>
[Fact]
public void SuccessfulRun_EmitsStartedThenCompletedInfoEvents()
{
var siteLog = new FakeSiteEventLogger();
// WP3.2: per-run Info events are opt-in now (default off, interval summary instead) —
// Per-run Info events are opt-in now (default off, interval summary instead) —
// this test pins the legacy per-run shape, so it opts back in explicitly.
var actor = BuildScriptActor(
CompileScript("return 7 * 6;"), Options(perRunScriptEvents: true), new SingleServiceProvider(siteLog));
@@ -419,11 +430,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that a failing run emits a Started info event then an Error event.</summary>
[Fact]
public void FailingRun_EmitsStartedInfoThenErrorEvent()
{
var siteLog = new FakeSiteEventLogger();
// WP3.2: the started Info event is opt-in (default off); the Error event on failure
// The started Info event is opt-in (default off); the Error event on failure
// stays unconditional regardless. This test pins the opted-in shape explicitly.
var actor = BuildScriptActor(
CompileScript("throw new InvalidOperationException(\"boom\");"),
@@ -443,11 +455,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that a fire-and-forget run needs no reply target.</summary>
[Fact]
public void FireAndForgetRun_NeedsNoReplyTarget()
{
var siteLog = new FakeSiteEventLogger();
// WP3.2: opted in so the completed Info event (the observable proof the
// Opted in so the completed Info event (the observable proof the
// fire-and-forget run actually ran to completion) is emitted; the policy itself is
// covered separately by ScriptRunEventPolicyTests.
var actor = BuildScriptActor(
@@ -463,7 +476,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
TimeSpan.FromSeconds(10));
}
// ── WP3.2: site_events volume policy — actor-level integration ────────────────
// ── site_events volume policy — actor-level integration ────────────────
//
// The pure ScriptRunEventPolicy matching rules (exact/wildcard/global/non-matching) are
// unit-tested directly in ScriptRunEventPolicyTests; these prove the policy is actually
@@ -471,6 +484,8 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
// the aggregate still sees the run, per-script opt-in works end to end, and the
// IOptionsMonitor read makes the switch hot-togglable without an actor restart.
/// <summary>Verifies that with default options a successful run emits no per-run events but the summary recorder still sees it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DefaultOptions_SuccessfulRun_EmitsNoPerRunEvents_ButTheSummaryRecorderSeesIt()
{
@@ -499,6 +514,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
flushLog.OfType("script").Single().Message);
}
/// <summary>Verifies that opting in a script by exact match restores per-run events for that script only.</summary>
[Fact]
public void PerScriptOptIn_ExactMatch_RestoresPerRunEventsForThatScriptOnly()
{
@@ -515,6 +531,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
AwaitAssert(() => Assert.Equal(2, siteLog.OfType("script").Count), TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that opting in with an instance wildcard restores per-run events.</summary>
[Fact]
public void PerScriptOptIn_InstanceWildcard_RestoresPerRunEvents()
{
@@ -531,6 +548,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
AwaitAssert(() => Assert.Equal(2, siteLog.OfType("script").Count), TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that flipping the options monitor changes the next run with no actor restart.</summary>
[Fact]
public void HotToggle_FlippingTheOptionsMonitor_ChangesTheNextRunWithNoActorRestart()
{
@@ -21,9 +21,9 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 6 — the per-script in-flight cap and its shed policy.
/// Test group 6 — the per-script in-flight cap and its shed policy.
///
/// <para>Before WP3.1 every trigger spawned another run unconditionally: a trigger firing
/// <para>Previously every trigger spawned another run unconditionally: a trigger firing
/// faster than its script completes produced unbounded fan-out onto a bounded thread pool.
/// The cap (<see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>, default 4) sheds the
/// NEWEST run instead. Keeping the four already queued/running — which are closest to their
@@ -40,6 +40,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptExecutionScheduler _scheduler = new(8);
/// <summary>Creates the shared script library and the gate that blocks scripts so runs stay in flight.</summary>
public ScriptRunShedTests()
{
var compilationService = new ScriptCompilationService(
@@ -49,6 +50,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
ShedHooks.Gate = new SemaphoreSlim(0);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
ShedHooks.Gate.Release(64);
@@ -79,6 +81,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
StuckScriptGraceMs = 120_000
};
/// <summary>Verifies that a fifth concurrent run is shed, counted and reported as a site event once, and the caller is answered with an error.</summary>
[Fact]
public void FifthConcurrentRun_IsShed_Counted_EventedOnce_AndAnsweredWithAnError()
{
@@ -144,6 +147,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
AwaitAssert(() => Assert.Equal(4, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
}
/// <summary>Verifies that alarm on-trigger runs are shed at the same cap and produce an alarm-scoped site event.</summary>
[Fact]
public void AlarmOnTriggerRuns_ShedAtTheSameCap_WithAnAlarmScopedSiteEvent()
{
@@ -233,17 +237,24 @@ public class ScriptRunShedTests : TestKit, IDisposable
{
private readonly List<AuditEvent> _events = new();
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (_events) _events.Add(evt);
return Task.CompletedTask;
}
/// <summary>Takes a copy of the rows captured so far.</summary>
/// <returns>An array of the captured audit events.</returns>
public AuditEvent[] Snapshot()
{
lock (_events) return _events.ToArray();
}
/// <summary>Polls until a captured row matches the predicate or the timeout expires.</summary>
/// <param name="predicate">The condition a row must satisfy.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The first matching audit event, or null if none matched in time.</returns>
public AuditEvent? WaitFor(Func<AuditEvent, bool> predicate, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -279,6 +290,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
private static bool IsFailedScriptRun(AuditEvent evt) =>
evt.Action == "ScriptTrace.ScriptRun" && evt.AsRow().Status.ToString() == "Failed";
/// <summary>Verifies that a traced run shed at the cap emits a failed script-run row parented to the writer.</summary>
[Fact]
public void TracedRun_ShedAtTheCap_EmitsAFailedScriptRunRow_ParentedToTheWriter()
{
@@ -325,6 +337,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
}
/// <summary>Verifies that a traced run suppressed by the minimum time between runs emits a failed script-run row.</summary>
[Fact]
public void TracedRun_SuppressedByMinTimeBetweenRuns_EmitsAFailedScriptRunRow()
{
@@ -30,14 +30,14 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <c>ApplyDeployment</c>. The two self-heal starts did not:
/// </para>
/// <list type="bullet">
/// <item><c>StartStoredInstance</c> — the path shared by the row-67 startup-reconcile
/// ensure-started command and the row-78 sweep.</item>
/// <item><c>StartStoredInstance</c> — the path shared by the startup-reconcile
/// ensure-started command and the sweep.</item>
/// <item><c>HandleEnableResult</c> — enabling an instance that was disabled at boot.</item>
/// </list>
///
/// <para>
/// <b>Row 85's live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC
/// replication; the row-78 sweep correctly started the enabled instances, and then every tag
/// <b>The live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC
/// replication; the sweep correctly started the enabled instances, and then every tag
/// subscribe failed for 6+ minutes with <c>Unknown connection: OPC PLC Simulator</c> /
/// <c>No connection actor for OPC PLC Simulator</c>, because nothing on that node had created the
/// connection actor. A second pair restart healed it — the boot load creates connections, so it
@@ -73,6 +73,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
/// </summary>
private readonly ConcurrentQueue<CreateConnectionCommand> _createdConnections = new();
/// <summary>Initializes a new instance of the <c>SelfHealCreatesDclConnectionTests</c> class.</summary>
public SelfHealCreatesDclConnectionTests()
{
_localDb = TestLocalDb.CreateTemp("dcl-selfheal-test");
@@ -84,6 +85,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
@@ -170,20 +172,21 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
return Task.CompletedTask;
}, TimeSpan.FromSeconds(20));
// ── The row-85 reproduction: a self-heal start must bring its connections with it ──
// ── The reproduction: a self-heal start must bring its connections with it ──
/// <summary>
/// THE row-85 reproduction. The node boots with an empty store, then a config for an ENABLED
/// THE reproduction. The node boots with an empty store, then a config for an ENABLED
/// instance appears in storage on its own — exactly what LocalDb's CDC sync applier does when
/// it seeds a fresh node from its peer. The row-78 sweep starts the Instance Actor; the DCL
/// it seeds a fresh node from its peer. The sweep starts the Instance Actor; the DCL
/// must be told to create the connection that instance's tags subscribe through.
///
/// <para>
/// <b>Fails before the fix</b> at the connection assert: the instance starts (row 78 works)
/// <b>Fails before the fix</b> at the connection assert: the instance starts (the sweep works)
/// but NO <see cref="CreateConnectionCommand"/> is ever sent, which is the live symptom —
/// the instance is up and every tag subscribe is answered <c>Unknown connection</c>.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SweepStartedInstance_AlsoCreatesItsDclConnection()
{
@@ -204,12 +207,13 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
}
/// <summary>
/// The row-67 startup-reconcile heal shares <c>StartStoredInstance</c> with the row-78 sweep,
/// The startup-reconcile heal shares <c>StartStoredInstance</c> with the sweep,
/// so it had the identical gap: the reconcile fetched and stored a config, the Deployment
/// Manager started the instance, and its connections were never created.
///
/// <para><b>Fails before the fix.</b></para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileEnsureStartedInstance_AlsoCreatesItsDclConnection()
{
@@ -235,6 +239,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
///
/// <para><b>Fails before the fix.</b></para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnabledInstance_DisabledAtBoot_AlsoCreatesItsDclConnection()
{
@@ -268,6 +273,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
/// <c>_createdConnections</c> hash guard inside <c>EnsureDclConnection</c> were ever lost.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SweepStartedInstance_CreatesTheConnection_ExactlyOnce_AcrossManySweeps()
{
@@ -290,6 +296,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
/// all. The hash guard is what makes the connection-ensure safe to sit in the creation
/// funnel, and this pins it against a config that never changes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_SendsNoConnectionCommands_WhenThereIsNothingToHeal()
{
@@ -319,6 +326,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
/// connections — it passes both before and after the fix, which is the point of a
/// refactor guard.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OrdinaryDeploy_StillCreatesItsDclConnection()
{
@@ -344,6 +352,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
/// stored BEFORE the manager starts, so the boot load — not the sweep — is what creates the
/// instance, and it must still bring its connections with it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BootLoadedInstance_StillCreatesItsDclConnection()
{
@@ -365,6 +374,8 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
/// </summary>
private sealed class DclConnectionSpy : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>DclConnectionSpy</c> class.</summary>
/// <param name="creates">The creates.</param>
public DclConnectionSpy(ConcurrentQueue<CreateConnectionCommand> creates)
{
Receive<CreateConnectionCommand>(creates.Enqueue);
@@ -25,6 +25,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
private readonly SiteStorageService _storage;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with a fresh local database and the actor-system fixtures.</summary>
public SiteReconciliationActorTests()
{
// SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the
@@ -35,6 +36,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
_storage.InitializeAsync().GetAwaiter().GetResult();
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actor system down FIRST: a reconcile continuation may still be
@@ -62,6 +64,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
TimeSpan.FromMilliseconds(50), // initial delay — fast for tests
askTimeout ?? TimeSpan.FromSeconds(5)))); // ask timeout
/// <summary>Verifies that an instance missing at the site is fetched from central and stored.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MissingInstance_IsFetchedAndStored()
{
@@ -101,6 +105,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
Assert.Equal("tok-b", call.Token);
}
/// <summary>Verifies that a stale instance is refreshed from central.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleInstance_IsRefreshed()
{
@@ -134,6 +140,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
Assert.Equal("tok-a", call.Token);
}
/// <summary>Verifies that an orphaned instance is logged but not deleted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Orphan_IsLoggedNotDeleted()
{
@@ -163,6 +171,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
Assert.Empty(fetcher.Calls);
}
/// <summary>Verifies that no fetch is made when there is no gap between central and the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoGap_DoesNotFetch()
{
@@ -181,6 +191,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
Assert.Empty(fetcher.Calls);
}
/// <summary>Verifies that a fetch failure for one item does not abort the remaining items.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PerItemFetchFailure_DoesNotAbortTheRest()
{
@@ -216,6 +228,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Verifies that a superseded item is skipped quietly while the other items are still applied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SupersededItem_IsSkippedQuietly_OthersStillApply()
{
@@ -259,6 +273,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Verifies that the actor survives a failed ask to central.</summary>
[Fact]
public void CentralAskFails_ActorSurvives()
{
@@ -289,10 +304,14 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
private sealed class FakeConfigFetcher : IDeploymentConfigFetcher
{
private readonly Func<string, Task<string>> _behavior;
/// <summary>Gets the recorded fetch calls (base URL, deployment id, token).</summary>
public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new();
/// <summary>Initializes the fake with the per-deployment-id behavior to run on each fetch.</summary>
/// <param name="behavior">Maps a deployment id to the fetch result.</param>
public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior;
/// <inheritdoc />
public async Task<string> FetchAsync(
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
{
@@ -305,11 +324,23 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
/// <summary>Thread-safe capturing logger so tests can assert on emitted warnings.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the captured log entries (level and formatted message).</summary>
public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
/// <inheritdoc />
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The log state type.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event id.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The associated exception, if any.</param>
/// <param name="formatter">Formats the state and exception into a message.</param>
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
@@ -13,10 +13,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 4 — the stuck-script watchdog now REPLACES the thread a wedged script is
/// The stuck-script watchdog now REPLACES the thread a wedged script is
/// holding, not just names it.
///
/// <para>Before WP3.1 the watchdog was observability only: a script blocked in synchronous,
/// <para>Previously the watchdog was observability only: a script blocked in synchronous,
/// uninterruptible I/O never observes the cooperative cancellation the timeout requests, so
/// the pool simply lost that dedicated thread — permanently, and silently apart from one log
/// line. Eight such scripts left the site with no script execution at all. The watchdog now
@@ -29,6 +29,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
/// <summary>Initializes a new instance of <c>StuckScriptWatchdogTests</c>.</summary>
public StuckScriptWatchdogTests()
{
var compilationService = new ScriptCompilationService(
@@ -38,6 +39,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
WatchdogHooks.Gate = new SemaphoreSlim(0);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Free any still-wedged worker before tearing down, so no test leaves a blocked thread.
@@ -81,6 +83,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
MaxScriptCallDepth = 10
};
/// <summary>Verifies that wedged script detaches its worker starts a replacement and drains when freed.</summary>
[Fact]
public void WedgedScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed()
{
@@ -107,7 +110,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
TimeSpan.FromSeconds(5));
// The replacement worker is live: a fresh script runs even though the pool's only
// original thread is still blocked. Before WP3.1 this reply never came.
// original thread is still blocked. Previously this reply never came.
var healthy = BuildScriptActor("Healthy", CompileRaw("return 7;"), options, scheduler, null);
var caller = CreateTestProbe();
healthy.Tell(new ScriptCallRequest("Healthy", null, 0, "corr-healthy"), caller.Ref);
@@ -124,6 +127,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
Assert.Equal(1, scheduler.MaximumConcurrencyLevel);
}
/// <summary>Verifies that at the detach cap no further replacement is started and an error event is emitted.</summary>
[Fact]
public void AtTheDetachCap_NoFurtherReplacementIsStarted_AndAnErrorEventIsEmitted()
{
@@ -155,6 +159,8 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
WatchdogHooks.Gate.Release(2);
}
/// <summary>Verifies that detached thread count is surfaced on the site health report.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachedThreadCount_IsSurfacedOnTheSiteHealthReport()
{
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test groups 1 and 2 — the regression pin for arch-review finding #4 (High).
/// Test groups 1 and 2 — the regression pin for the trigger-evaluation starvation finding.
///
/// <para><b>The finding.</b> Trigger-expression evaluation used to be queued onto the same
/// fixed 8-thread <see cref="ScriptExecutionScheduler"/> that runs blocking script bodies. Eight
@@ -33,6 +33,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
/// <summary>Initializes a new instance of the <c>TriggerEvalStarvationTests</c> class.</summary>
public TriggerEvalStarvationTests()
{
var compilationService = new ScriptCompilationService(
@@ -42,6 +43,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
StarvationHooks.Gate = new SemaphoreSlim(0);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
StarvationHooks.Gate.Release(32);
@@ -70,11 +72,11 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
}
/// <summary>
/// GROUP 1 — the finding-#4 regression pin. Eight script bodies blocked on a semaphore
/// GROUP 1 — the trigger-evaluation starvation regression pin. Eight script bodies blocked on a semaphore
/// occupy every thread of an 8-thread pool; an Expression-triggered alarm must still raise
/// in well under 2 s.
///
/// <para>On the pre-WP3.1 wiring this test does not merely fail slowly — it never
/// <para>On the previous wiring this test does not merely fail slowly — it never
/// completes: the evaluation sits in the same FIFO behind eight bodies that only unblock
/// after the assertion window, and its 2 s timeout has not even started ticking.</para>
/// </summary>
@@ -138,6 +140,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
/// false) at its timeout rather than stalling indefinitely — and the trigger must drain
/// rather than park, so the next change still evaluates.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SaturatedEvalGate_ResolvesTheQueuedEvaluationAtItsEnqueueAnchoredDeadline()
{
@@ -169,7 +172,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
// 3. The next evaluation can never acquire the gate. Its deadline was armed at
// enqueue, so it cancels at ~1 s, is treated as false, and clears the alarm.
// Pre-WP3.1 the clock started at dequeue, so this would hang forever.
// Previously the clock started at dequeue, so this would hang forever.
var stopwatch = Stopwatch.StartNew();
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 2, "Good", DateTimeOffset.UtcNow));
var cleared = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
@@ -29,6 +29,7 @@ public class DeployCompileValidatorTests
NativeAlarmSources = new List<ResolvedNativeAlarmSource>()
});
/// <summary>Verifies that broken script, returns errors.</summary>
[Fact]
public void BrokenScript_ReturnsErrors()
{
@@ -37,12 +38,14 @@ public class DeployCompileValidatorTests
Assert.Contains(errors, e => e.Contains("S1"));
}
/// <summary>Verifies that valid script, returns no errors.</summary>
[Fact]
public void ValidScript_ReturnsNoErrors()
{
Assert.Empty(NewValidator().Validate(ConfigWith("return 1 + 1;")));
}
/// <summary>Verifies that broken trigger expression, returns errors.</summary>
[Fact]
public void BrokenTriggerExpression_ReturnsErrors()
{
@@ -69,6 +72,7 @@ public class DeployCompileValidatorTests
Assert.Contains(errors, e => e.Contains("script-trigger-S1"));
}
/// <summary>Verifies that malformed json, returns single deserialize error.</summary>
[Fact]
public void MalformedJson_ReturnsSingleDeserializeError()
{
@@ -77,6 +81,7 @@ public class DeployCompileValidatorTests
Assert.Contains("deserialize", errors[0], StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that missing on trigger script, returns error.</summary>
[Fact]
public void MissingOnTriggerScript_ReturnsError()
{
@@ -20,6 +20,8 @@ public class HttpDeploymentConfigFetcherTests
// Happy path
// -------------------------------------------------------------------------
/// <summary>Verifies that a 200 response returns the body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_200_ReturnsBody()
{
@@ -32,6 +34,8 @@ public class HttpDeploymentConfigFetcherTests
Assert.Equal(expectedBody, result);
}
/// <summary>Verifies that the request URL is built correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_200_RequestUrlIsCorrect()
{
@@ -46,6 +50,8 @@ public class HttpDeploymentConfigFetcherTests
handler.LastRequest!.RequestUri!.ToString());
}
/// <summary>Verifies that the deployment token header is set on the request.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_200_SetsDeploymentTokenHeader()
{
@@ -59,6 +65,8 @@ public class HttpDeploymentConfigFetcherTests
Assert.Equal("my-secret-token", handler.LastRequest.Headers.GetValues("X-Deployment-Token").Single());
}
/// <summary>Verifies that a base URL with a trailing slash does not produce a double slash.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_BaseUrlWithTrailingSlash_NoDoubleSlashInUrl()
{
@@ -73,6 +81,8 @@ public class HttpDeploymentConfigFetcherTests
Assert.Equal("http://central/api/internal/deployments/dep-1/config", url);
}
/// <summary>Verifies that a deployment id needing escaping is escaped exactly once in the URL.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_DeploymentIdNeedingEscaping_EscapesOnceInUrl()
{
@@ -94,6 +104,8 @@ public class HttpDeploymentConfigFetcherTests
// 404 → IsSuperseded = true
// -------------------------------------------------------------------------
/// <summary>Verifies that a 404 response throws with the superseded flag set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_404_ThrowsWithIsSupersededTrue()
{
@@ -111,6 +123,8 @@ public class HttpDeploymentConfigFetcherTests
// Other HTTP errors → IsSuperseded = false
// -------------------------------------------------------------------------
/// <summary>Verifies that a 401 response throws with the superseded flag clear.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_401_ThrowsWithIsSupersededFalse()
{
@@ -124,6 +138,8 @@ public class HttpDeploymentConfigFetcherTests
Assert.Contains("401", ex.Message);
}
/// <summary>Verifies that a 500 response throws with the superseded flag clear.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_500_ThrowsWithIsSupersededFalse()
{
@@ -141,6 +157,8 @@ public class HttpDeploymentConfigFetcherTests
// Transport failure → DeploymentConfigFetchException (not raw HttpRequestException)
// -------------------------------------------------------------------------
/// <summary>Verifies that a transport exception is wrapped in a deployment config fetch exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_TransportException_WrapsAsDeploymentConfigFetchException()
{
@@ -155,6 +173,8 @@ public class HttpDeploymentConfigFetcherTests
Assert.Contains("Connection refused", ex.Message);
}
/// <summary>Verifies that an HTTP client timeout is wrapped as a transport error rather than a cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_HttpClientTimeout_WrapsAsTransportError_NotCancellation()
{
@@ -173,6 +193,8 @@ public class HttpDeploymentConfigFetcherTests
// Cancellation propagates as-is
// -------------------------------------------------------------------------
/// <summary>Verifies that a cancelled token propagates an operation-canceled exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_CancelledToken_PropagatesOperationCanceledException()
{
@@ -194,14 +216,19 @@ public class HttpDeploymentConfigFetcherTests
{
private readonly HttpStatusCode _status;
private readonly string _body;
/// <summary>The last request the handler received.</summary>
public HttpRequestMessage? LastRequest { get; private set; }
/// <summary>Creates a handler that always replies with the given status and body.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="body">The response body to return.</param>
public StubHandler(HttpStatusCode status, string body)
{
_status = status;
_body = body;
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
@@ -216,14 +243,18 @@ public class HttpDeploymentConfigFetcherTests
private sealed class ThrowingHandler : HttpMessageHandler
{
private readonly Exception _ex;
/// <summary>Creates a handler that throws the given exception.</summary>
/// <param name="ex">The exception to throw.</param>
public ThrowingHandler(Exception ex) => _ex = ex;
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> throw _ex;
}
private sealed class DelayHandler : HttpMessageHandler
{
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
await Task.Delay(Timeout.Infinite, ct);
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Integration;
/// which is complex for unit tests. Marked with Category=Integration
/// for separate test run configuration.
///
/// WP-7: Dual-Node Recovery verification points:
/// Dual-Node Recovery verification points:
/// - Both nodes are seed nodes (config-verified)
/// - min-nr-of-members=1 allows single-node cluster formation
/// - First node forms cluster, singleton starts, rebuilds from SQLite
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Integration;
/// </summary>
public class FailoverIntegrationTests
{
/// <summary>Verifies that a single node forms a singleton cluster and rebuilds its state from SQLite.</summary>
[Fact]
[Trait("Category", "Integration")]
public void SingleNode_FormsSingletonCluster_RebuildFromSQLite()
@@ -27,11 +28,12 @@ public class FailoverIntegrationTests
Assert.True(true, "Covered by DeploymentManagerActorTests");
}
/// <summary>Verifies that a graceful shutdown hands the singleton over to the standby.</summary>
[Fact]
[Trait("Category", "Integration")]
public void GracefulShutdown_SingletonHandover()
{
// WP-6: CoordinatedShutdown triggers graceful cluster leave.
// CoordinatedShutdown triggers graceful cluster leave.
// The AkkaHostedService.StopAsync runs CoordinatedShutdown which:
// 1. Leaves the cluster gracefully
// 2. Singleton manager detects leave and starts handover
@@ -42,6 +44,7 @@ public class FailoverIntegrationTests
Assert.True(true, "Requires multi-process test infrastructure");
}
/// <summary>Verifies that after a crash the split-brain resolver downs the node and the singleton restarts on the standby.</summary>
[Fact]
[Trait("Category", "Integration")]
public void CrashRecovery_SBRDownsNode_SingletonRestartsOnStandby()
@@ -57,11 +60,12 @@ public class FailoverIntegrationTests
Assert.True(true, "Requires multi-process test infrastructure");
}
/// <summary>Verifies that when both nodes restart they recover their state from SQLite.</summary>
[Fact]
[Trait("Category", "Integration")]
public void DualNodeRecovery_BothNodesRestart_FromSQLite()
{
// WP-7: When both nodes restart (full site power cycle):
// When both nodes restart (full site power cycle):
// 1. First node starts, forms cluster (min-nr-of-members=1)
// 2. Singleton starts on first node
// 3. DeploymentManager reads all configs from persistent SQLite
@@ -10,6 +10,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests;
/// </summary>
public class NegativeTests
{
/// <summary>Verifies the case: schema, no Alarm State Table.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Schema_NoAlarmStateTable()
{
@@ -69,6 +71,8 @@ public class NegativeTests
}
}
/// <summary>Verifies the case: schema, no Local Config Authoring.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Schema_NoLocalConfigAuthoring()
{
@@ -106,6 +110,7 @@ public class NegativeTests
Assert.Equal(2, tableCount);
}
/// <summary>Verifies the case: site Node, does Not Bind Http Ports.</summary>
[Fact]
public void SiteNode_DoesNotBindHttpPorts()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// WP-33: Local Artifact Storage tests — shared scripts, external systems,
/// Local Artifact Storage tests — shared scripts, external systems,
/// database connections, notification lists.
/// </summary>
/// <remarks>
@@ -17,11 +17,14 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
private readonly TestLocalDb _localDb;
private SiteStorageService _storage = null!;
/// <summary>Creates a temporary LocalDb for the tests.</summary>
public ArtifactStorageTests()
{
_localDb = TestLocalDb.CreateTemp("artifact-test");
}
/// <summary>Creates and initializes the storage service under test.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_storage = new SiteStorageService(
@@ -30,8 +33,11 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
await _storage.InitializeAsync();
}
/// <summary>Does nothing; there is no asynchronous cleanup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Disposes the test database and deletes its files.</summary>
public void Dispose()
{
// Dispose first — the master connection anchors the WAL, so the sidecars
@@ -43,6 +49,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// ── Shared Script Storage ──
/// <summary>Verifies that a stored shared script round-trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreSharedScript_RoundTrips()
{
@@ -56,6 +64,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("int", scripts[0].ReturnDefinition);
}
/// <summary>Verifies that storing a shared script with an existing name upserts it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreSharedScript_Upserts_OnConflict()
{
@@ -68,6 +78,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("{\"x\":\"int\"}", scripts[0].ParameterDefinitions);
}
/// <summary>Verifies that multiple shared scripts can be stored side by side.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreSharedScript_MultipleScripts()
{
@@ -79,6 +91,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(3, scripts.Count);
}
/// <summary>Verifies that a shared script with null optional fields can be stored.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreSharedScript_NullableFields()
{
@@ -92,6 +106,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// ── External System Storage ──
/// <summary>Verifies that storing an external system does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreExternalSystem_DoesNotThrow()
{
@@ -102,6 +118,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// No exception = success. Query verification would need a Get method.
}
/// <summary>Verifies that storing an external system with an existing name upserts it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreExternalSystem_Upserts()
{
@@ -113,6 +131,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// ── Database Connection Storage ──
/// <summary>Verifies that storing a database connection does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDatabaseConnection_DoesNotThrow()
{
@@ -120,6 +140,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
"MainDB", "Server=localhost;Database=main", 3, TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that storing a database connection with an existing name upserts it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDatabaseConnection_Upserts()
{
@@ -139,6 +161,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// shared script / DB connection / data connection) stayed orphaned on the site
// forever and remained callable from site scripts.
/// <summary>Verifies that deleting shared scripts except a keep set removes orphans and keeps the present ones.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteSharedScriptsExcept_RemovesOrphans_KeepsPresent()
{
@@ -153,6 +177,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(["Keep1", "Keep2"], names);
}
/// <summary>Verifies that deleting external systems except a keep set removes orphans and keeps the present ones.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteExternalSystemsExcept_RemovesOrphans_KeepsPresent()
{
@@ -165,6 +191,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(["MES"], await TableNamesAsync("external_systems"));
}
/// <summary>Verifies that deleting database connections with an empty keep set deletes all of them.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDatabaseConnectionsExcept_EmptyKeepSet_DeletesAll()
{
@@ -178,6 +206,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Empty(await TableNamesAsync("database_connections"));
}
/// <summary>Verifies that deleting data connection definitions except a keep set removes orphans and keeps the present ones.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDataConnectionDefinitionsExcept_RemovesOrphans_KeepsPresent()
{
@@ -191,6 +221,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(["PlcA"], names);
}
/// <summary>Verifies that deleting with no orphans returns an empty result and is idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteRowsExcept_NoOrphans_ReturnsEmpty_AndIsIdempotent()
{
@@ -213,10 +245,10 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
return names;
}
// ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ──
// ── Central-only notif/SMTP purge ──
//
// Notification config is central-only. The site-side write paths and
// SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3/#23);
// SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3);
// PurgeCentralOnlyNotificationConfigAsync is retained as the security cleanup for
// DBs written by older builds. These tests seed the (still-present) tables via raw
// SQL — the only way rows can now exist — and assert the purge empties them.
@@ -257,6 +289,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
return (long)(await command.ExecuteScalarAsync())!;
}
/// <summary>Verifies that the central-only purge removes persisted notification list and SMTP rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeCentralOnlyNotificationConfig_RemovesPersistedNotificationListsAndSmtpRows()
{
@@ -276,6 +310,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(0, await RowCountAsync("smtp_configurations"));
}
/// <summary>Verifies that the central-only purge is idempotent on empty tables.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeCentralOnlyNotificationConfig_IsIdempotent_OnEmptyTables()
{
@@ -287,8 +323,10 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(0, await RowCountAsync("smtp_configurations"));
}
// ── Schema includes all WP-33 tables ──
// ── Schema includes all artifact tables ──
/// <summary>Verifies that initialization creates all artifact tables.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initialize_CreatesAllArtifactTables()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// Task 8, site-secrets artifact deploy: site-local <c>deployed_secrets</c>
/// Site-secrets artifact deploy: site-local <c>deployed_secrets</c>
/// (LocalDb-replicated ciphertext) and <c>mirrored_secrets</c> (node-local ledger) stores.
/// </summary>
/// <remarks>
@@ -20,19 +20,25 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
private readonly TestLocalDb _localDb;
private SiteStorageService _storage = null!;
/// <summary>Initializes the test class and creates the backing storage.</summary>
public DeployedSecretsStorageTests()
{
_localDb = TestLocalDb.CreateTemp("deployed-secrets");
}
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
await _storage.InitializeAsync();
}
/// <summary>Releases the resources used by this instance asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -42,6 +48,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
// ── deployed_secrets: full-set upsert / soft-delete reconciliation ──
/// <summary>Verifies that an upsert is treated as the full set and marks absent rows as removed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_IsFullSet_MarksAbsentRowsRemoved()
{
@@ -57,6 +65,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.True(rows.Single(r => r.Name == "B").IsRemoved);
}
/// <summary>Verifies that the first upsert applies every row and removes nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_FirstCall_AppliesEveryRow_AndRemovesNothing()
{
@@ -67,6 +77,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Empty(removed);
}
/// <summary>Verifies that a removed row is not reported as removed again when the set is re-applied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_ReAppliedRow_IsNotReportedAsRemovedTwice()
{
@@ -80,6 +92,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Empty(removedSecond);
}
/// <summary>Verifies that a removed row can be re-applied and flips back to not removed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_RowThatWasRemoved_CanBeReAppliedAndFlipsBackToNotRemoved()
{
@@ -98,6 +112,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(2, b.Secret.Revision);
}
/// <summary>Verifies that upserting an empty set marks every existing row as removed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_EmptySet_MarksEveryExistingRowRemoved()
{
@@ -112,6 +128,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.All(rows, r => Assert.True(r.IsRemoved));
}
/// <summary>Verifies that reading the deployed secrets round-trips every field of the secret artifact.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedSecretsAsync_RoundTripsEveryFieldOfTheSecretArtifact()
{
@@ -138,6 +156,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(art.Description, row.Secret.Description);
}
/// <summary>Verifies that reading the deployed secrets includes rows marked as removed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedSecretsAsync_IncludesRemovedRows()
{
@@ -153,6 +173,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
// ── security: row_json holds only base64 ciphertext, never plaintext ──
/// <summary>Verifies that the stored row JSON deserializes to a secret artifact with no extra properties.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RowJson_DeserializesToSecretArtifact_WithNoExtraProperties()
{
@@ -204,6 +226,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(expected, propertyNames);
}
/// <summary>Verifies that the stored row JSON encodes every byte-array field as Base64 text.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RowJson_EncodesEveryByteArrayField_AsBase64Text()
{
@@ -244,6 +268,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
// ── mirrored_secrets: node-local ledger ──
/// <summary>Verifies that a mirrored secret that is set can be read back.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MirroredSecrets_SetThenGet_RoundTrips()
{
@@ -256,6 +282,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(3, row.Revision);
}
/// <summary>Verifies that setting a mirrored secret twice updates the existing entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MirroredSecrets_Set_IsUpsert()
{
@@ -268,6 +296,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(2, row.Revision);
}
/// <summary>Verifies that removing a mirrored secret deletes its ledger row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MirroredSecrets_Remove_DeletesTheLedgerRow()
{
@@ -278,6 +308,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Empty(await _storage.GetMirroredSecretsAsync());
}
/// <summary>Verifies that removing an unknown mirrored secret name does nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MirroredSecrets_Remove_OfAnUnknownName_IsANoOp()
{
@@ -286,6 +318,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Empty(await _storage.GetMirroredSecretsAsync());
}
/// <summary>Verifies that the mirrored-secrets ledger is independent of the deployed secrets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MirroredSecrets_IsIndependentOf_DeployedSecrets()
{
@@ -304,8 +338,10 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("A", mirrored.Name);
}
// ── secret_mirror_status (review finding M1) ──
// ── secret_mirror_status (standby mirror failures) ──
/// <summary>Verifies that mirror status round-trips with one row per node, ordered by node name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecretMirrorStatus_RoundTrips_OneRowPerNode_OrderedByNodeName()
{
@@ -324,6 +360,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(at, rows[1].CheckedAtUtc);
}
/// <summary>Verifies that setting the mirror status updates that node's existing row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecretMirrorStatus_Set_UpsertsTheNodesRow()
{
@@ -336,6 +374,8 @@ public class DeployedSecretsStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(at.AddHours(1), row.CheckedAtUtc);
}
/// <summary>Verifies that an unparseable mirror status row reads as a failure rather than as healthy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecretMirrorStatus_UnparseableRow_ReadsAsAFailure_NotAsHealthy()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// Task 14: site-local SQLite <c>native_alarm_state</c> store — mirrored native alarm
/// Site-local SQLite <c>native_alarm_state</c> store — mirrored native alarm
/// condition snapshots keyed by (instance, source canonical name, source reference).
/// </summary>
/// <remarks>
@@ -17,19 +17,26 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
private readonly TestLocalDb _localDb;
private SiteStorageService _storage = null!;
/// <summary>Initializes the tests with a temporary local database.</summary>
public NativeAlarmStateStoreTests()
{
_localDb = TestLocalDb.CreateTemp("nas");
}
/// <summary>Prepares the test fixture before the first test runs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
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;
/// <summary>Verifies that an upserted alarm row can be read back unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_Then_Get_RoundTrips()
{
@@ -43,6 +50,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
Assert.Equal(DateTimeOffset.UnixEpoch, rows[0].LastTransitionAt);
}
/// <summary>Verifies that upserting the same key replaces the condition and timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_SameKey_ReplacesConditionAndTimestamp()
{
@@ -56,6 +65,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), rows[0].LastTransitionAt);
}
/// <summary>Verifies that a read is scoped to the instance and source canonical name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Get_ScopesToInstanceAndSourceCanonicalName()
{
@@ -69,6 +80,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
Assert.Equal("Tank01.Hi", rows[0].SourceReference);
}
/// <summary>Verifies that deleting removes only the single targeted row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Delete_RemovesSingleRow()
{
@@ -82,6 +95,8 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
Assert.Equal("Tank01.Lo", rows[0].SourceReference);
}
/// <summary>Verifies that clearing an instance removes all of its sources and leaves other instances untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ClearForInstance_RemovesAllSourcesForInstanceOnly()
{
@@ -96,6 +111,7 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
Assert.Single(await _storage.GetNativeAlarmsAsync("other", "SrcA"));
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
// Dispose first — the master connection anchors the WAL, so the sidecars
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// </summary>
public class SiteStorageSchemaTests
{
/// <summary>Verifies that applying the schema is idempotent and creates every table.</summary>
[Fact]
public void Apply_IsIdempotent_AndCreatesEveryTable()
{
@@ -20,11 +20,14 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
private readonly TestLocalDb _localDb;
private SiteStorageService _storage = null!;
/// <summary>Initializes a new instance of the <c>SiteStorageServiceTests</c> class.</summary>
public SiteStorageServiceTests()
{
_localDb = TestLocalDb.CreateTemp("site-storage-test");
}
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_storage = new SiteStorageService(
@@ -33,8 +36,11 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
await _storage.InitializeAsync();
}
/// <summary>Releases the resources used by this instance asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
// Dispose first — the master connection anchors the WAL, so the sidecars
@@ -44,6 +50,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
TestLocalDb.DeleteFiles(path);
}
/// <summary>Verifies that initialization creates the tables without error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InitializeAsync_CreatesTablesWithoutError()
{
@@ -52,6 +60,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
await _storage.InitializeAsync();
}
/// <summary>Verifies that initialization enables the write-ahead-log journal mode.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initialize_EnablesWalJournalMode()
{
@@ -72,6 +82,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Equal("wal", mode.ToLowerInvariant());
}
/// <summary>Verifies that a stored deployed config can be retrieved unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreAndRetrieve_DeployedConfig_RoundTrips()
{
@@ -88,7 +100,7 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.True(configs[0].IsEnabled);
}
// ── Register P1-4: projected single-row reads ──
// ── Projected single-row reads ──
//
// GetAllDeployedConfigsAsync + FirstOrDefault materializes every deployed config —
// including every ConfigJson blob — to find one row. These lock in that
@@ -98,6 +110,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
// defect here is pure I/O cost, not an observable behavior difference, so these tests
// pin the new projection's contract rather than reproduce a behavior bug.
/// <summary>Verifies that a single-config read returns the same row as reading all configs and picking the match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedConfigAsync_ReturnsSameRowAs_GetAllThenFirstOrDefault()
{
@@ -120,6 +134,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(viaFullScan.DeployedAt, viaProjection.DeployedAt);
}
/// <summary>Verifies that reading the config of an unknown instance returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedConfigAsync_UnknownInstance_ReturnsNull()
{
@@ -131,6 +147,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Null(result);
}
/// <summary>Verifies that a metadata read returns the same identity as reading all configs and picking the match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedConfigMetadataAsync_ReturnsSameIdentityAs_GetAllThenFirstOrDefault()
{
@@ -151,6 +169,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(viaFullScan.IsEnabled, metadata.IsEnabled);
}
/// <summary>Verifies that reading the metadata of an unknown instance returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedConfigMetadataAsync_UnknownInstance_ReturnsNull()
{
@@ -162,6 +182,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Null(result);
}
/// <summary>Verifies that storing a deployed config for an existing instance replaces the previous row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDeployedConfig_Upserts_OnConflict()
{
@@ -178,6 +200,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.False(configs[0].IsEnabled);
}
/// <summary>Verifies that removing a deployed config also removes its static overrides.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemoveDeployedConfig_RemovesConfigAndOverrides()
{
@@ -194,6 +218,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Empty(overrides);
}
/// <summary>Verifies that setting the instance enabled state updates the stored flag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetInstanceEnabled_UpdatesFlag()
{
@@ -211,6 +237,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.True(configs[0].IsEnabled);
}
/// <summary>Verifies that setting the enabled state of a nonexistent instance does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetInstanceEnabled_NonExistent_DoesNotThrow()
{
@@ -220,6 +248,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
// ── Static Override Tests ──
/// <summary>Verifies that a static override can be stored and read back unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetAndGetStaticOverride_RoundTrips()
{
@@ -231,6 +261,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Equal("98.6", overrides["Temperature"]);
}
/// <summary>Verifies that setting a static override that already exists replaces its value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetStaticOverride_Upserts_OnConflict()
{
@@ -243,6 +275,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Equal("100.0", overrides["Temperature"]);
}
/// <summary>Verifies that clearing static overrides removes all of an instance's overrides.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ClearStaticOverrides_RemovesAll()
{
@@ -255,6 +289,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.Empty(overrides);
}
/// <summary>Verifies that static overrides are isolated per instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetStaticOverrides_IsolatedPerInstance()
{
@@ -270,6 +306,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.True(pump2.ContainsKey("Pressure"));
}
/// <summary>Verifies that multiple instances have independent lifecycles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MultipleInstances_IndependentLifecycle()
{
@@ -289,6 +327,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
// ── Negative Tests ──
/// <summary>Verifies that the schema does not contain an alarm state table.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Schema_DoesNotContain_AlarmStateTable()
{
@@ -339,6 +379,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
await cmd.ExecuteNonQueryAsync();
}
/// <summary>Verifies that a conditional store inserts the config when no row exists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDeployedConfigIfNewer_NoExistingRow_Inserts()
{
@@ -356,6 +398,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.True(configs[0].IsEnabled);
}
/// <summary>Verifies that a conditional store overwrites an existing older row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDeployedConfigIfNewer_ExistingOlderRow_Overwrites()
{
@@ -375,6 +419,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.False(configs[0].IsEnabled);
}
/// <summary>Verifies that a conditional store does nothing when the existing row is newer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDeployedConfigIfNewer_ExistingNewerRow_IsNoop()
{
@@ -397,6 +443,8 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
Assert.False(configs[0].IsEnabled);
}
/// <summary>Verifies that a conditional store does nothing when the deployment times are equal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreDeployedConfigIfNewer_EqualDeployedAt_IsNoop()
{
@@ -6,13 +6,13 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Repositories;
/// <summary>
/// SiteRuntime-006 / SiteRuntime-007 regression tests for the site-local repositories.
/// Regression tests for the site-local repositories.
///
/// SiteRuntime-006: the repositories must obtain a SQLite connection through
/// The repositories must obtain a SQLite connection through
/// <see cref="SiteStorageService.CreateConnection"/>, not by reading a private field
/// via reflection.
///
/// SiteRuntime-007: the synthetic integer IDs derived from entity names must be stable
/// The synthetic integer IDs derived from entity names must be stable
/// across process restarts (a freshly-constructed service/repository), so an ID handed
/// to a caller still resolves the same entity later.
/// </summary>
@@ -20,11 +20,13 @@ public class SiteRepositoryTests : IDisposable
{
private readonly TestLocalDb _localDb;
/// <summary>Initializes a temporary local database for the tests.</summary>
public SiteRepositoryTests()
{
_localDb = TestLocalDb.CreateTemp("site-repo-test");
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
// Dispose first — the master connection anchors the WAL, so the sidecars
@@ -46,10 +48,11 @@ public class SiteRepositoryTests : IDisposable
=> new(_localDb.Db, NullLogger<SiteStorageService>.Instance);
/// <summary>
/// SiteRuntime-006: an external system stored via <see cref="SiteStorageService"/>
/// An external system stored via <see cref="SiteStorageService"/>
/// can be read back through the repository — proving the repository's connection
/// (now obtained from <see cref="SiteStorageService.CreateConnection"/>) is valid.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_RoundTripsStoredDefinition()
{
@@ -71,6 +74,7 @@ public class SiteRepositoryTests : IDisposable
/// <see cref="SiteStorageService.StoreExternalSystemAsync"/> must round-trip through the
/// site SQLite store and back out via the repository, so site-side calls honor it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteStorage_ExternalSystem_TimeoutSeconds_RoundTrips()
{
@@ -93,6 +97,7 @@ public class SiteRepositoryTests : IDisposable
/// silently falling back to <c>DefaultMaxRetries</c> (50) x <c>DefaultRetryInterval</c> (30 s).
/// Before the fix the site table had no such columns and this read back 0 / TimeSpan.Zero.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteStorage_ExternalSystem_RetryPolicy_RoundTrips()
{
@@ -116,6 +121,7 @@ public class SiteRepositoryTests : IDisposable
/// engine defaults apply. This is what makes the new columns behaviourally invisible until
/// an operator actually configures a policy.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteStorage_ExternalSystem_RetryPolicy_DefaultsToUnset()
{
@@ -133,11 +139,12 @@ public class SiteRepositoryTests : IDisposable
}
/// <summary>
/// SiteRuntime-007: the synthetic ID for an external system must be identical when
/// The synthetic ID for an external system must be identical when
/// the storage service and repository are re-created (simulating a process restart).
/// With the old <see cref="string.GetHashCode()"/> the ID was randomized per process
/// and a by-ID lookup after a restart would fail.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_SyntheticId_IsStableAcrossRestart()
{
@@ -162,12 +169,13 @@ public class SiteRepositoryTests : IDisposable
Assert.Equal("StableSystem", found.Name);
}
// ── ExternalSystemGateway-011: name-keyed repository lookups ──
// ── Name-keyed repository lookups ──
/// <summary>
/// ExternalSystemGateway-011: the site repository's name-keyed external-system
/// The site repository's name-keyed external-system
/// lookup returns the matching row, and the same synthetic ID as the by-ID path.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_GetByName_ReturnsMatchingDefinition()
{
@@ -192,8 +200,9 @@ public class SiteRepositoryTests : IDisposable
}
/// <summary>
/// ExternalSystemGateway-011: a missing name resolves to <c>null</c>.
/// A missing name resolves to <c>null</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_GetByName_MissingName_ReturnsNull()
{
@@ -208,9 +217,10 @@ public class SiteRepositoryTests : IDisposable
}
/// <summary>
/// ExternalSystemGateway-011: the site repository's name-keyed method lookup
/// The site repository's name-keyed method lookup
/// returns the method scoped to its parent system, or <c>null</c> for a miss.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_GetMethodByName_ResolvesScopedToSystem()
{
@@ -233,9 +243,10 @@ public class SiteRepositoryTests : IDisposable
}
/// <summary>
/// ExternalSystemGateway-011: the site repository's name-keyed database-connection
/// The site repository's name-keyed database-connection
/// lookup returns the matching row, or <c>null</c> for a miss.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DatabaseConnectionRepository_GetByName_ReturnsMatchingDefinition()
{
@@ -258,18 +269,19 @@ public class SiteRepositoryTests : IDisposable
}
// NotificationRepository_SyntheticId_IsStableAcrossRestart was removed 2026-07-10
// (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository —
// (arch-review 08 §1.3) along with the vestigial SiteNotificationRepository —
// notification config is central-only and never lives on a site. The synthetic-ID
// stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart.
// ── WP2.6a: ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ──
// ── ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ──
/// <summary>
/// WP2.6a: two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/>
/// Two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/>
/// (the production DI shape — one singleton cache, many scoped repository instances) must
/// see the SAME cached snapshot: a row written after the cache was already populated by
/// the first repository is invisible to the second until the cache is invalidated.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_SharedCache_HitServesStaleSnapshotUntilInvalidated()
{
@@ -298,11 +310,12 @@ public class SiteRepositoryTests : IDisposable
}
/// <summary>
/// WP2.6a: the by-ID lookups (system and method) must resolve correctly out of the
/// The by-ID lookups (system and method) must resolve correctly out of the
/// cached snapshot — proving the O(1) id-index build in <c>LoadSnapshotAsync</c> is
/// wired correctly for both entity kinds, not just the by-name path already covered
/// by ExternalSystemGateway-011.
/// by the name-keyed lookup tests.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_SharedCache_ByIdLookups_ResolveSystemAndMethod()
{
@@ -334,12 +347,13 @@ public class SiteRepositoryTests : IDisposable
}
/// <summary>
/// WP2.6a: a repository constructed via the single-arg (no shared cache) constructor
/// A repository constructed via the single-arg (no shared cache) constructor
/// gets its own private cache — a second such repository over the same storage must
/// see rows written after the first repository's cache was already populated,
/// preserving the pre-cache "always fresh" behavior for callers that opt out of
/// sharing.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemRepository_PrivateCache_DoesNotShareAcrossInstances()
{
@@ -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()
{
@@ -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[]
{
@@ -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()
{
@@ -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()
{
@@ -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&amp;F buffering, <c>WasBuffered=false</c>),
/// the S&amp;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&amp;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&amp;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()
{
@@ -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.
@@ -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()
{
@@ -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.
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets;
/// <summary>
/// Task 9 fix round 2 (review <c>task-9-rereview-1.md</c> recommendation #2): runs
/// Runs
/// <see cref="DeployedSecretsMirror"/> against the REAL <c>ZB.MOM.WW.Secrets</c> stack —
/// <see cref="SqliteSecretStore"/> + <see cref="AesGcmEnvelopeCipher"/> +
/// <see cref="DefaultSecretResolver"/> over a real temp-file SQLite database — instead of the
@@ -40,6 +40,8 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo
private DefaultSecretResolver _resolver = null!;
private SiteStorageService _storage = null!;
/// <summary>Prepares the test fixture before the first test runs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
var connectionFactory = new SecretsSqliteConnectionFactory(_secretsDbPath);
@@ -58,8 +60,11 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo
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;
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -71,6 +76,8 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo
}
}
/// <summary>Verifies that against the real secrets stack an encrypt, apply, delete and re-add cycle yields a recovered row that resolves to the original plaintext.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RealStack_EncryptApplyDeleteReAdd_TheRecoveredRowResolvesToTheOriginalPlaintext()
{
@@ -150,10 +157,15 @@ public sealed class DeployedSecretsMirrorRealStackTests : IAsyncLifetime, IDispo
{
private readonly byte[] _key = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
/// <summary>Initializes the provider with a key encryption key id.</summary>
/// <param name="kekId">The key encryption key id reported by the provider.</param>
public FakeMasterKeyProvider(string kekId) => KekId = kekId;
/// <summary>Gets the key encryption key id.</summary>
public string KekId { get; }
/// <summary>Returns the fixed master key.</summary>
/// <returns>The master key bytes.</returns>
public ReadOnlyMemory<byte> GetMasterKey() => _key;
}
}
@@ -12,11 +12,11 @@ using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets;
/// <summary>
/// Task 9, site-secrets artifact deploy: <see cref="DeployedSecretsMirror"/> — per-node apply
/// Site-secrets artifact deploy: <see cref="DeployedSecretsMirror"/> — per-node apply
/// + decrypt-verify of <c>deployed_secrets</c> into this node's own secret store.
/// </summary>
/// <remarks>
/// Fix round 1 (review <c>task-9-review.md</c>): <see cref="FakeSecretStore"/> now honours the
/// <see cref="FakeSecretStore"/> honours the
/// real <c>SqliteSecretStore</c> semantics — <c>ApplyReplicatedAsync</c> is strict
/// last-writer-wins via <see cref="SecretLastWriterWins.IsNewer"/>, and <c>DeleteAsync</c>
/// tombstones rather than hard-deleting — so tests exercise the same conflict shapes the real
@@ -26,11 +26,13 @@ public class DeployedSecretsMirrorTests : IDisposable
{
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test class with a temporary LocalDb database.</summary>
public DeployedSecretsMirrorTests()
{
_localDb = TestLocalDb.CreateTemp("deployed-secrets-mirror");
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -40,6 +42,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Apply / remove / out-of-band ──
/// <summary>Verifies that reconcile applies and verifies deployed secrets and deletes only the names it mirrored.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_Applies_Verifies_AndOnlyDeletesWhatItMirrored()
{
@@ -69,6 +73,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.True(store.IsTombstoned("a"));
}
/// <summary>Verifies that a newer out-of-band secret is neither overwritten nor ledgered and survives a later removal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_OutOfBandNewerSecret_NotOverwritten_NotLedgered_AndSurvivesLaterRemoval()
{
@@ -101,6 +107,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(operatorCiphertext, store.Rows["a"].Ciphertext);
}
/// <summary>Verifies that a removed then re-added secret recovers from its tombstone, reapplies and verifies.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_RemoveThenReAdd_TombstoneIsRecovered_ReappliesAndVerifies()
{
@@ -142,6 +150,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(1, mirrored.Single(m => m.Name == "a").Revision);
}
/// <summary>Verifies that a clock-skewed recovered row still converges on the next central revision.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_ClockSkewedRecoveredRow_StillConvergesOnNextCentralRevision()
{
@@ -187,6 +197,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(2, mirrored.Single(m => m.Name == "a").Revision);
}
/// <summary>Verifies that reconcile reapplies a secret only when its revision changes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_ReAppliesOnlyWhenRevisionChanges()
{
@@ -208,6 +220,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Same-revision drift (final review I1) ──
/// <summary>Verifies that an owned name deleted locally is healed and verified rather than misreported as a KEK mismatch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_OwnedNameDeletedLocally_IsHealedAndVerified_NotMisreportedAsKekMismatch()
{
@@ -238,6 +252,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(1, (await storage.GetMirroredSecretsAsync()).Single(m => m.Name == "a").Revision);
}
/// <summary>Verifies that an owned name edited locally is reverted to the deployed ciphertext.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_OwnedNameEditedLocally_IsRevertedToTheDeployedCiphertext()
{
@@ -263,6 +279,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Contains("a", invalidator.Invalidated);
}
/// <summary>Verifies that owned-name drift is logged once per drift rather than on every pass.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_OwnedNameDrift_LogsOncePerDriftNotEveryPass()
{
@@ -281,8 +299,10 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Error);
}
// ── Unreadable row_json (final review M4) ──
// ── Unreadable row_json ──
/// <summary>Verifies that an unparseable row JSON fails only that name while other names still apply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_UnparseableRowJson_IsAFailedEntryForThatName_OthersStillApply()
{
@@ -301,6 +321,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.False(store.Contains("bad"));
}
/// <summary>Verifies that an unparseable removed row still removes a mirrored name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_UnparseableRemovedRow_StillRemovesAMirroredName()
{
@@ -326,6 +348,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(1, await command.ExecuteNonQueryAsync());
}
/// <summary>Verifies that reconcile applies through <c>ApplyReplicatedAsync</c> with a row mapped from the secret artifact.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_AppliesThroughApplyReplicatedAsync_MappedFromSecretArtifact()
{
@@ -340,6 +364,9 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(SecretContentType.Text, applied.ContentType);
}
/// <summary>Verifies that reconcile round-trips every secret content type.</summary>
/// <param name="contentType">The secret content type under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(SecretContentType.Text)]
[InlineData(SecretContentType.ConnectionString)]
@@ -358,6 +385,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Per-row isolation (ruling 2) ──
/// <summary>Verifies that when one row throws the other rows still apply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_OneRowThrows_OthersStillApply()
{
@@ -376,6 +405,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.False(store.Contains("bad"));
}
/// <summary>Verifies that a throwing row's failure is de-spammed like any other failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_OneRowThrows_FailureIsDeSpammedLikeAnyOtherFailure()
{
@@ -395,6 +426,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Cache invalidation (ruling 3) ──
/// <summary>Verifies that a successful apply invalidates the cache before verification.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_SuccessfulApply_InvalidatesCache_BeforeVerify()
{
@@ -406,6 +439,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Contains("a", invalidator.Invalidated);
}
/// <summary>Verifies that a removal invalidates the cache.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_Removal_InvalidatesCache()
{
@@ -420,6 +455,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Contains("a", invalidator.Invalidated);
}
/// <summary>Verifies that reconcile still applies when no cache invalidator is registered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_WithNoInvalidatorRegistered_StillApplies()
{
@@ -442,6 +479,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Decrypt verification ──
/// <summary>Verifies that a resolver decrypt exception is reported together with the KEK id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_DecryptFailure_ExceptionFromResolver_IsReportedWithKekId()
{
@@ -454,11 +493,13 @@ public class DeployedSecretsMirrorTests : IDisposable
var message = Assert.Single(r.Failed);
Assert.Contains("kek-central", message);
Assert.Contains("'a'", message);
// FailedNames (Task 10): the name behind each Failed entry, in lockstep order, so a
// FailedNames: the name behind each Failed entry, in lockstep order, so a
// caller (DeploymentManagerActor) can tell whether a failure belongs to names it shipped.
Assert.Equal(["a"], r.FailedNames);
}
/// <summary>Verifies that a null resolve is also reported as a decrypt failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_DecryptFailure_NullResolve_IsAlsoReported()
{
@@ -471,6 +512,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Single(r.Failed);
}
/// <summary>Verifies that reconcile verifies on every pass even when the secret is already applied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_VerifiesEveryPass_EvenWhenAlreadyApplied()
{
@@ -491,6 +534,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Error-log de-spam (ruling 1 of the original task, unaffected by fix round 1) ──
/// <summary>Verifies that a decrypt failure logs an error the first time, then debug, then an error again after a success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_DecryptFailure_LogsErrorOnlyFirstTime_ThenDebug_ThenErrorAgainAfterASuccess()
{
@@ -527,6 +572,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(LogLevel.Error, failureEntriesAfterRecovery[3].Level);
}
/// <summary>Verifies that a decrypt failure on a new revision logs an error again even without an intervening success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_DecryptFailure_NewRevision_LogsErrorAgain_EvenWithoutASuccessInBetween()
{
@@ -551,6 +598,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Cancellation + concurrency (ruling 6) ──
/// <summary>Verifies that a pre-cancelled token makes reconcile throw an <see cref="OperationCanceledException"/>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileAsync_PreCancelledToken_ThrowsOperationCanceled()
{
@@ -562,6 +611,8 @@ public class DeployedSecretsMirrorTests : IDisposable
() => mirror.ReconcileAsync(cts.Token));
}
/// <summary>Verifies that concurrent reconcile callers both complete without applying twice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileAsync_ConcurrentCallers_BothCompleteWithoutDoubleApply()
{
@@ -583,6 +634,7 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Dispose (ruling 6) ──
/// <summary>Verifies that disposing the mirror does not throw and is idempotent.</summary>
[Fact]
public void Dispose_DoesNotThrow_AndIsIdempotent()
{
@@ -602,6 +654,8 @@ public class DeployedSecretsMirrorTests : IDisposable
// ── Hosted loop ──
/// <summary>Verifies that the background loop runs once at start and again every interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_RunsOnceAtStart_AndAgainEveryInterval()
{
@@ -627,6 +681,8 @@ public class DeployedSecretsMirrorTests : IDisposable
}
}
/// <summary>Verifies that a per-row failure is isolated and the loop recovers once the failure clears.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_PerRowFailure_IsIsolated_AndLoopRecoversOnceCleared()
{
@@ -656,8 +712,10 @@ public class DeployedSecretsMirrorTests : IDisposable
}
}
// ── Per-node status row for central (review finding M1) ──
// ── Per-node status row for central ──
/// <summary>Verifies that the first pass after startup writes this node's status row even when healthy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_FirstPassAfterStartup_WritesThisNodesRow_EvenWhenHealthy()
{
@@ -672,6 +730,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(time.Now, status.CheckedAtUtc);
}
/// <summary>Verifies that an unchanged failure set is not rewritten on later passes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_UnchangedFailureSet_IsNotRewrittenOnLaterPasses()
{
@@ -695,6 +755,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Contains("kek-central", failure.Message);
}
/// <summary>Verifies that an unchanged failure set is refreshed once the refresh interval has passed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_UnchangedFailureSet_IsRefreshedOnceTheRefreshIntervalHasPassed()
{
@@ -715,6 +777,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(time.Now, Assert.Single(await storage.GetSecretMirrorStatusesAsync()).CheckedAtUtc);
}
/// <summary>Verifies that a status row deleted by a peer prune is rewritten on the next pass.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_RowDeletedByAPeerPrune_IsRewrittenOnTheNextPass()
{
@@ -735,6 +799,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(time.Now, status.CheckedAtUtc);
}
/// <summary>Verifies that a failure set change rewrites the status row and that it clears once healed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_FailureSetChange_RewritesTheRow_AndClearsWhenHealed()
{
@@ -762,6 +828,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal(time.Now, healed.CheckedAtUtc);
}
/// <summary>Verifies that the status row carries only names and messages, never ciphertext.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_RowCarriesNamesAndMessagesOnly_NeverCiphertext()
{
@@ -780,6 +848,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.DoesNotContain(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), json);
}
/// <summary>Verifies that a status write failure does not break the pass and is retried on the next pass.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_WriteFailure_DoesNotBreakThePass_AndIsRetriedNextPass()
{
@@ -807,6 +877,8 @@ public class DeployedSecretsMirrorTests : IDisposable
Assert.Equal("node-a", Assert.Single(await storage.GetSecretMirrorStatusesAsync()).NodeName);
}
/// <summary>Verifies that without a node identity the status row falls back to the machine name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Status_WithoutNodeIdentity_FallsBackToMachineName()
{
@@ -819,6 +891,7 @@ public class DeployedSecretsMirrorTests : IDisposable
private sealed class FixedNodeIdentity(string? name) : INodeIdentityProvider
{
/// <inheritdoc />
public string? NodeName { get; } = name;
}
@@ -890,11 +963,23 @@ public class DeployedSecretsMirrorTests : IDisposable
/// <summary>Thread-safe capturing logger so tests can assert on emitted log levels/messages, mirroring SiteReconciliationActorTests.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the captured log entries in emission order.</summary>
public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
/// <inheritdoc />
IDisposable? ILogger.BeginScope<TState>(TState state) => 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>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="logLevel">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 logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
@@ -904,21 +989,28 @@ public class DeployedSecretsMirrorTests : IDisposable
/// <summary>Minimal controllable <see cref="TimeProvider"/> — mirrors the pattern used by <c>ApiMethodCacheTests</c>.</summary>
private sealed class FakeTimeProvider : TimeProvider
{
/// <summary>Gets or sets the current time reported by this provider.</summary>
public DateTimeOffset Now { get; set; }
/// <summary>Initializes the provider at the given start time.</summary>
/// <param name="start">The initial time.</param>
public FakeTimeProvider(DateTimeOffset start) => Now = start;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => Now;
}
/// <summary>Records every <see cref="ISecretCacheInvalidator.Invalidate"/> call by secret name.</summary>
private sealed class FakeSecretCacheInvalidator : ISecretCacheInvalidator
{
/// <summary>Gets the secret names invalidated so far, in call order.</summary>
public List<string> Invalidated { get; } = new();
/// <summary>Records the invalidated secret name.</summary>
/// <param name="name">The secret name.</param>
public void Invalidate(SecretName name) => Invalidated.Add(name.Value);
}
/// <summary>
/// In-memory <see cref="ISecretStore"/> honouring the REAL <c>SqliteSecretStore</c>
/// semantics (fix round 1, review Important #1): <see cref="ApplyReplicatedAsync"/> is
/// semantics: <see cref="ApplyReplicatedAsync"/> is
/// strict last-writer-wins via <see cref="SecretLastWriterWins.IsNewer"/> and applies
/// verbatim (no revision bump); <see cref="DeleteAsync"/> tombstones in place (bumps
/// revision, stamps <see cref="Now"/>) rather than hard-deleting.
@@ -941,6 +1033,10 @@ public class DeployedSecretsMirrorTests : IDisposable
public IReadOnlyDictionary<string, StoredSecret> Rows => _rows;
/// <summary>Seeds a LIVE row directly (bypassing ApplyReplicatedAsync/LWW) — an out-of-band secret.</summary>
/// <param name="name">The secret name.</param>
/// <param name="updatedUtc">The last-updated timestamp stamped on the row.</param>
/// <param name="revision">The row revision.</param>
/// <param name="ciphertext">The ciphertext bytes, or null for a default placeholder.</param>
public void Seed(string name, DateTimeOffset updatedUtc, long revision = 1, byte[]? ciphertext = null) =>
_rows[name] = new StoredSecret
{
@@ -959,14 +1055,26 @@ public class DeployedSecretsMirrorTests : IDisposable
};
/// <summary>True if the row is present and NOT tombstoned.</summary>
/// <param name="name">The secret name.</param>
/// <returns>True if a live row exists for the name.</returns>
public bool Contains(string name) => _rows.TryGetValue(name, out var row) && !row.IsDeleted;
/// <summary>True if the row is present as a tombstone.</summary>
/// <param name="name">The secret name.</param>
/// <returns>True if the row exists and is tombstoned.</returns>
public bool IsTombstoned(string name) => _rows.TryGetValue(name, out var row) && row.IsDeleted;
/// <summary>Looks up the secret by name.</summary>
/// <param name="name">The secret name.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The secret, or null when none matches.</returns>
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult(_rows.TryGetValue(name.Value, out var row) ? row : null);
/// <summary>Stores or replaces the row in the in-memory fake.</summary>
/// <param name="row">The secret row.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
{
_rows[row.Name.Value] = row;
@@ -974,6 +1082,10 @@ public class DeployedSecretsMirrorTests : IDisposable
}
/// <summary>Tombstones (does not hard-delete) — matches <c>SqliteSecretStore.DeleteAsync</c>.</summary>
/// <param name="name">The secret name.</param>
/// <param name="actor">The actor recorded on the tombstone.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>True if a live row was tombstoned; false if it was absent or already deleted.</returns>
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
{
if (!_rows.TryGetValue(name.Value, out var existing) || existing.IsDeleted)
@@ -992,13 +1104,23 @@ public class DeployedSecretsMirrorTests : IDisposable
return Task.FromResult(true);
}
/// <summary>Returns an empty secret listing.</summary>
/// <param name="includeDeleted">Whether deleted secrets are included, which the fake ignores.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>An empty list.</returns>
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretMetadata>>([]);
/// <summary>Returns an empty manifest.</summary>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>An empty manifest.</returns>
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
/// <summary>Strict LWW on (UpdatedUtc, Revision) via <see cref="SecretLastWriterWins.IsNewer"/>; applied verbatim — matches <c>SqliteSecretStore</c>.</summary>
/// <param name="row">The replicated row to apply.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
{
if (ThrowOnApply)
@@ -1016,6 +1138,11 @@ public class DeployedSecretsMirrorTests : IDisposable
return Task.CompletedTask;
}
/// <summary>Reports that no rewrap was applied.</summary>
/// <param name="rewrappedRow">The rewrapped secret row.</param>
/// <param name="expectedCurrentWrappedDek">The wrapped data key the row is expected to hold currently.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Always false.</returns>
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
Task.FromResult(false);
@@ -1033,17 +1160,24 @@ public class DeployedSecretsMirrorTests : IDisposable
public FakeSecretStore? BackingStore { get; set; }
/// <summary>Row absent/tombstoned path — <c>DefaultSecretResolver.GetAsync</c> returns null.</summary>
/// <param name="name">The secret name that should resolve to null.</param>
public void ReturnNullFor(string name) => _nullReturning.Add(name);
/// <summary>Decryption/integrity failure path — <c>DefaultSecretResolver.GetAsync</c> rethrows <c>SecretDecryptionException</c>.</summary>
/// <param name="name">The secret name whose resolution should throw.</param>
public void ThrowFor(string name) => _throwing.Add(name);
/// <summary>Clears every configured null-returning and throwing name.</summary>
public void ClearFailures()
{
_nullReturning.Clear();
_throwing.Clear();
}
/// <summary>Looks up the secret by name.</summary>
/// <param name="name">The secret name.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The secret, or null when none matches.</returns>
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Interlocked.Increment(ref CallCount);
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Secrets;
/// <summary>
/// Review finding M1: <see cref="SecretMirrorStatusReporter"/> forwards every node's
/// <see cref="SecretMirrorStatusReporter"/> forwards every node's
/// replicated <c>secret_mirror_status</c> row onto the site health report via the collector.
/// </summary>
public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
@@ -16,14 +16,19 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
private readonly TestLocalDb _localDb = TestLocalDb.CreateTemp("secret-mirror-status");
private SiteStorageService _storage = null!;
/// <summary>Creates the site storage service and initializes its schema.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
await _storage.InitializeAsync();
}
/// <summary>Completes immediately; the synchronous <c>Dispose</c> releases the resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -31,6 +36,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
TestLocalDb.DeleteFiles(path);
}
/// <summary>Verifies that a probe pushes every node's status row onto the next health report.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Probe_PushesEveryNodesRow_OntoTheNextReport()
{
@@ -51,6 +58,7 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
Assert.Equal("OPC-PWD", Assert.Single(report.SecretMirrorStatuses[1].Failures).SecretName);
}
/// <summary>Verifies that the report field is null before any probe has run.</summary>
[Fact]
public void BeforeAnyProbe_TheReportFieldIsNull()
{
@@ -59,6 +67,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
Assert.Null(report.SecretMirrorStatuses);
}
/// <summary>Verifies that a probe with no rows yet pushes an empty list rather than null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Probe_NoRowsYet_PushesAnEmptyList_NotNull()
{
@@ -71,6 +81,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
Assert.Empty(collector.CollectReport("site-a").SecretMirrorStatuses!);
}
/// <summary>Verifies that a read failure during a probe keeps the previous snapshot and does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Probe_ReadFailure_KeepsThePreviousSnapshot_AndDoesNotThrow()
{
@@ -96,6 +108,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
// ── Stale rows (decommissioned / renamed nodes) ──
/// <summary>Verifies that a row older than the stale-after window is left off the report and deleted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Probe_RowOlderThanStaleAfter_IsLeftOffTheReport_AndDeleted()
{
@@ -114,6 +128,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
Assert.Equal(["node-a"], (await _storage.GetSecretMirrorStatusesAsync()).Select(s => s.NodeName));
}
/// <summary>Verifies that a row exactly at the stale cutoff is kept.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Probe_RowExactlyAtTheCutoff_IsKept()
{
@@ -130,6 +146,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
Assert.Single(await _storage.GetSecretMirrorStatusesAsync());
}
/// <summary>Verifies that a probe prunes nothing during the start-up grace period and prunes once it has passed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Probe_DuringTheStartUpGrace_PrunesNothing_ThenPrunesAfterIt()
{
@@ -151,6 +169,8 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
Assert.Empty(await _storage.GetSecretMirrorStatusesAsync());
}
/// <summary>Verifies that a conditional delete does not remove a row the node rewrote after it was read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConditionalDelete_DoesNotRemoveARowTheNodeRewroteAfterTheRead()
{
@@ -168,12 +188,15 @@ public class SecretMirrorStatusReporterTests : IAsyncLifetime, IDisposable
private sealed class MutableTime(DateTimeOffset now) : TimeProvider
{
/// <summary>Gets or sets the instant that the clock reports.</summary>
public DateTimeOffset Now { get; set; } = now;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class FixedTime(DateTimeOffset now) : TimeProvider
{
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => now;
}
}
@@ -15,6 +15,7 @@ public class SiteRuntimeOptionsValidatorTests
private static ValidateOptionsResult Validate(SiteRuntimeOptions options) =>
new SiteRuntimeOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -22,6 +23,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that zero startup batch size is rejected.</summary>
[Fact]
public void ZeroStartupBatchSize_IsRejected()
{
@@ -31,6 +33,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("StartupBatchSize", result.FailureMessage);
}
/// <summary>Verifies that zero script execution thread count is rejected.</summary>
[Fact]
public void ZeroScriptExecutionThreadCount_IsRejected()
{
@@ -40,6 +43,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("ScriptExecutionThreadCount", result.FailureMessage);
}
/// <summary>Verifies that zero tag subscribe retry interval ms is rejected.</summary>
[Fact]
public void ZeroTagSubscribeRetryIntervalMs_IsRejected()
{
@@ -49,6 +53,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("TagSubscribeRetryIntervalMs", result.FailureMessage);
}
/// <summary>Verifies that negative stuck script grace ms is rejected.</summary>
[Fact]
public void NegativeStuckScriptGraceMs_IsRejected()
{
@@ -58,6 +63,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("StuckScriptGraceMs", result.FailureMessage);
}
/// <summary>Verifies that zero stream buffer size is rejected.</summary>
[Fact]
public void ZeroStreamBufferSize_IsRejected()
{
@@ -67,6 +73,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("StreamBufferSize", result.FailureMessage);
}
/// <summary>Verifies that negative startup batch delay ms is rejected.</summary>
[Fact]
public void NegativeStartupBatchDelayMs_IsRejected()
{
@@ -76,6 +83,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("StartupBatchDelayMs", result.FailureMessage);
}
/// <summary>Verifies that zero alarm publish queue capacity is rejected.</summary>
[Fact]
public void ZeroAlarmPublishQueueCapacity_IsRejected()
{
@@ -85,7 +93,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("AlarmPublishQueueCapacity", result.FailureMessage);
}
// ── WP3.1 options ──────────────────────────────────────────────────────────────
// ── Script-pool options ──────────────────────────────────────────────────────────────
/// <summary>
/// The ceiling is the upper bound of the instance-scaled pool; below the floor it would
@@ -104,6 +112,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("ScriptExecutionMaxThreadCount", result.FailureMessage);
}
/// <summary>Verifies that equal script execution thread count and max is accepted.</summary>
[Fact]
public void EqualScriptExecutionThreadCountAndMax_IsAccepted()
{
@@ -126,6 +135,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("TriggerEvalMaxConcurrency", result.FailureMessage);
}
/// <summary>Verifies that zero trigger eval timeout seconds is rejected.</summary>
[Fact]
public void ZeroTriggerEvalTimeoutSeconds_IsRejected()
{
@@ -159,6 +169,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Equal(200, new SiteRuntimeOptions().MaxLogLinesPerRun);
}
/// <summary>Verifies that zero max concurrent runs per script is rejected.</summary>
[Fact]
public void ZeroMaxConcurrentRunsPerScript_IsRejected()
{
@@ -169,6 +180,7 @@ public class SiteRuntimeOptionsValidatorTests
}
// --- Lever 1 (2026-08-28): tag-subscribe backoff + in-flight ceiling ---
/// <summary>Verifies that tag subscribe retry max interval ms below floor is rejected.</summary>
[Fact]
public void TagSubscribeRetryMaxIntervalMs_BelowFloor_IsRejected()
{
@@ -182,6 +194,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("TagSubscribeRetryMaxIntervalMs", result.FailureMessage);
}
/// <summary>Verifies that tag subscribe retry max interval ms equal to floor is valid.</summary>
[Fact]
public void TagSubscribeRetryMaxIntervalMs_EqualToFloor_IsValid()
{
@@ -193,6 +206,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that zero tag subscribe in flight ceiling ms is rejected.</summary>
[Fact]
public void ZeroTagSubscribeInFlightCeilingMs_IsRejected()
{
@@ -202,6 +216,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage);
}
/// <summary>Verifies that script file access defaults to disabled and is valid.</summary>
[Fact]
public void ScriptFileAccess_DefaultsToDisabled_AndIsValid()
{
@@ -210,6 +225,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(Validate(options).Succeeded);
}
/// <summary>Verifies that script file access relative root is rejected.</summary>
[Fact]
public void ScriptFileAccess_RelativeRoot_IsRejected()
{
@@ -222,6 +238,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("ScriptFileAccess:AllowedRoots:0", result.FailureMessage);
}
/// <summary>Verifies that script file access absolute root is accepted.</summary>
[Fact]
public void ScriptFileAccess_AbsoluteRoot_IsAccepted()
{
@@ -230,6 +247,8 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(Validate(options).Succeeded);
}
/// <summary>Verifies that script file access max file bytes out of range is rejected.</summary>
/// <param name="max">The max value.</param>
[Theory]
[InlineData(0L)]
[InlineData(64L * 1024 * 1024 + 1)]
@@ -244,6 +263,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("ScriptFileAccess:MaxFileBytes", result.FailureMessage);
}
/// <summary>Verifies that zero secret mirror status refresh interval is rejected.</summary>
[Fact]
public void ZeroSecretMirrorStatusRefreshInterval_IsRejected()
{
@@ -253,6 +273,7 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Contains("SecretMirrorStatusRefreshInterval", result.FailureMessage);
}
/// <summary>Verifies that secret mirror status stale after under two refreshes is rejected.</summary>
[Fact]
public void SecretMirrorStatusStaleAfter_UnderTwoRefreshes_IsRejected()
{
@@ -12,14 +12,14 @@ public class SiteRuntimeServiceRegistrationTests
/// <summary>
/// Register row 100. <c>ScriptCompilationService</c> owns the
/// <c>SiteScriptCompileCache</c>, so "one Roslyn compile per script body per process"
/// (N4 / WP3.1's warm-then-gate deploy path) is precisely the statement that DI hands
/// (the warm-then-gate deploy path) is precisely the statement that DI hands
/// out ONE of these per host. The cache stopped being <c>static</c> when row 100 removed
/// the process-global state tests were clobbering; this registration is what replaced
/// the static as the guarantee, so it is pinned here.
///
/// <para>Downgrading this to Scoped or Transient would give every consumer — the deploy
/// gate, each Instance Actor's PreStart, SharedScriptLibrary — its own empty cache and
/// silently reintroduce the recompile storms WP3.1 removed, with no functional test
/// silently reintroduce the recompile storms the warm-then-gate path removed, with no functional test
/// failing. This assertion is the only thing that would notice.</para>
/// </summary>
[Fact]
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming;
/// <summary>
/// WP2.6d: the hosted reporter must lift <see cref="SiteStreamManager.AlarmPublishDroppedCount"/>
/// The hosted reporter must lift <see cref="SiteStreamManager.AlarmPublishDroppedCount"/>
/// onto the site health report via <see cref="ISiteHealthCollector"/>. Uses the real
/// collector (mirrors <c>ScriptSchedulerStatsReporterTests</c> — NSubstitute is not
/// referenced by this test project). The queue's own drop-counting mechanism is verified
@@ -16,8 +16,11 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming;
/// </summary>
public class SiteStreamAlarmDropReporterTests : TestKit, IDisposable
{
/// <inheritdoc />
void IDisposable.Dispose() => Shutdown();
/// <summary>Verifies that reporter pushes alarm drop count to collector.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reporter_PushesAlarmDropCountToCollector()
{
@@ -8,13 +8,14 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming;
/// <summary>
/// WP-23: Site-Wide Akka Stream tests.
/// WP-25: Debug View Backend tests (subscribe/unsubscribe).
/// Site-Wide Akka Stream tests.
/// Debug View Backend tests (subscribe/unsubscribe).
/// </summary>
public class SiteStreamManagerTests : TestKit, IDisposable
{
private readonly SiteStreamManager _streamManager;
/// <summary>Initializes a new instance of the <see cref="SiteStreamManagerTests"/> class.</summary>
public SiteStreamManagerTests()
{
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
@@ -23,11 +24,13 @@ public class SiteStreamManagerTests : TestKit, IDisposable
_streamManager.Initialize(Sys);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
}
/// <summary>Verifies the case: subscribe, creates Subscription.</summary>
[Fact]
public void Subscribe_CreatesSubscription()
{
@@ -38,6 +41,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
Assert.Equal(1, _streamManager.SubscriptionCount);
}
/// <summary>Verifies the case: unsubscribe, removes Subscription.</summary>
[Fact]
public void Unsubscribe_RemovesSubscription()
{
@@ -48,12 +52,14 @@ public class SiteStreamManagerTests : TestKit, IDisposable
Assert.Equal(0, _streamManager.SubscriptionCount);
}
/// <summary>Verifies the case: unsubscribe, invalid Id, returns False.</summary>
[Fact]
public void Unsubscribe_InvalidId_ReturnsFalse()
{
Assert.False(_streamManager.Unsubscribe("nonexistent"));
}
/// <summary>Verifies the case: publish Attribute Value Changed, forwards To Subscriber.</summary>
[Fact]
public void PublishAttributeValueChanged_ForwardsToSubscriber()
{
@@ -69,6 +75,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
Assert.Equal("Temperature", received.AttributeName);
}
/// <summary>Verifies the case: publish Alarm State Changed, forwards To Subscriber.</summary>
[Fact]
public void PublishAlarmStateChanged_ForwardsToSubscriber()
{
@@ -84,6 +91,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
Assert.Equal(AlarmState.Active, received.State);
}
/// <summary>Verifies the case: publish Attribute Value Changed, filtersby Instance.</summary>
[Fact]
public void PublishAttributeValueChanged_FiltersbyInstance()
{
@@ -103,6 +111,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies the case: subscribe Site Alarms, forwards Alarms For All Instances, but Not Attributes.</summary>
[Fact]
public void SubscribeSiteAlarms_ForwardsAlarmsForAllInstances_ButNotAttributes()
{
@@ -135,6 +144,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies the case: subscribe Site Alarms, subscription Removable Via Unsubscribe.</summary>
[Fact]
public void SubscribeSiteAlarms_SubscriptionRemovableViaUnsubscribe()
{
@@ -147,6 +157,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
Assert.Equal(0, _streamManager.SubscriptionCount);
}
/// <summary>Verifies the case: remove Subscriber, removes All Subscriptions For Actor.</summary>
[Fact]
public void RemoveSubscriber_RemovesAllSubscriptionsForActor()
{
@@ -160,10 +171,10 @@ public class SiteStreamManagerTests : TestKit, IDisposable
Assert.Equal(0, _streamManager.SubscriptionCount);
}
// ── WP2.6d: separate alarm publish path, drop counter, skip-at-zero-subscribers ──
// ── separate alarm publish path, drop counter, skip-at-zero-subscribers ──
/// <summary>
/// WP2.6d: alarm state changes now travel a dedicated publish source, isolated from
/// Alarm state changes now travel a dedicated publish source, isolated from
/// the (far higher-volume) attribute path — a burst of attribute events for OTHER
/// instances, interleaved with an alarm-only subscriber's events, must not cause any
/// alarm to be lost.
@@ -176,7 +187,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
// A storm of attribute events for a DIFFERENT instance — none of which the
// alarm subscriber is even listening to — interleaved with alarm events. Before
// WP2.6d these shared one upstream buffer; now they are fully separate sources.
// Previously these shared one upstream buffer; now they are fully separate sources.
for (var i = 0; i < 500; i++)
{
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
@@ -191,7 +202,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
}
/// <summary>
/// WP2.6d: with zero subscribers of any kind, PublishAlarmStateChanged/
/// With zero subscribers of any kind, PublishAlarmStateChanged/
/// PublishAttributeValueChanged must be no-ops — no exception, and (for alarms) the
/// event never reaches the bounded hand-off queue, so
/// <see cref="SiteStreamManager.AlarmPublishDroppedCount"/> stays at zero rather than
@@ -217,7 +228,7 @@ public class SiteStreamManagerTests : TestKit, IDisposable
}
/// <summary>
/// WP2.6d: once the bounded alarm hand-off queue (the exact factory
/// Once the bounded alarm hand-off queue (the exact factory
/// <see cref="SiteStreamManager.Initialize"/> wires into the drop-count callback) is
/// full, a further write evicts the oldest queued item and fires the drop callback.
/// Exercised directly against the queue factory — deterministic, no actor
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
/// <summary>
/// M1 Site Event Logging categories: a capturing fake <see cref="ISiteEventLogger"/>
/// A capturing fake <see cref="ISiteEventLogger"/>
/// used by the actor tests to assert that the right operational events are emitted.
/// Thread-safe — the actors fire-and-forget <c>LogEventAsync</c> from background
/// tasks, so multiple captures can land concurrently.
@@ -29,9 +29,12 @@ public sealed class FakeSiteEventLogger : ISiteEventLogger
public IReadOnlyList<Entry> Entries => _entries.ToArray();
/// <summary>Captured events filtered to a single category.</summary>
/// <param name="eventType">The event category to filter on.</param>
/// <returns>The captured events with the given category.</returns>
public IReadOnlyList<Entry> OfType(string eventType) =>
_entries.Where(e => e.EventType == eventType).ToArray();
// Captures the event so tests can assert on it.
/// <inheritdoc />
public Task LogEventAsync(
string eventType,
@@ -45,6 +48,7 @@ public sealed class FakeSiteEventLogger : ISiteEventLogger
return Task.CompletedTask;
}
// Always zero for this fake.
/// <inheritdoc />
public long FailedWriteCount => 0;
}
@@ -63,12 +67,12 @@ public sealed class FakeSiteEventLogger : ISiteEventLogger
/// </summary>
/// <param name="logger">The event logger to resolve for <see cref="ISiteEventLogger"/>.</param>
/// <param name="optionsMonitor">
/// WP3.2: optional <see cref="IOptionsMonitor{TOptions}"/> for <see cref="SiteRuntimeOptions"/>
/// Optional <see cref="IOptionsMonitor{TOptions}"/> for <see cref="SiteRuntimeOptions"/>
/// — resolves for the hot-toggle tests; null (the default) exercises the launcher's
/// constructor-passed-options fallback, matching every pre-WP3.2 caller of this class.
/// constructor-passed-options fallback, matching every earlier caller of this class.
/// </param>
/// <param name="summaryRecorder">
/// WP3.2: optional <see cref="ScriptRunSummaryRecorder"/> — resolves so a test can assert on
/// Optional <see cref="ScriptRunSummaryRecorder"/> — resolves so a test can assert on
/// the aggregate counters a run produced; null (the default) exercises the launcher's
/// null-safe skip path.
/// </param>
@@ -82,7 +86,9 @@ public sealed class SingleServiceProvider(
private readonly IOptionsMonitor<SiteRuntimeOptions>? _optionsMonitor = optionsMonitor;
private readonly ScriptRunSummaryRecorder? _summaryRecorder = summaryRecorder;
/// <inheritdoc />
/// <summary>Resolves the logger, the scope factory and the optional options monitor and summary recorder.</summary>
/// <param name="serviceType">The service type to resolve.</param>
/// <returns>The matching service, or null when the type is not served.</returns>
public object? GetService(Type serviceType)
{
if (serviceType == typeof(ISiteEventLogger)) return _logger;
@@ -92,12 +98,13 @@ public sealed class SingleServiceProvider(
return null;
}
/// <inheritdoc />
/// <summary>Creates a scope that re-exposes this provider.</summary>
/// <returns>This instance as the scope.</returns>
public IServiceScope CreateScope() => this;
/// <inheritdoc />
/// <summary>Gets the provider for this scope, which is this instance.</summary>
public IServiceProvider ServiceProvider => this;
/// <inheritdoc />
/// <summary>Does nothing; the scope owns no resources.</summary>
public void Dispose() { }
}
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
/// <summary>
/// WP3.1 parity pin 3: an <see cref="IServiceProvider"/> that counts
/// An <see cref="IServiceProvider"/> that counts
/// <see cref="IServiceScopeFactory.CreateScope"/> calls and each scope's
/// <see cref="IDisposable.Dispose"/>, so a test can assert that one script run creates
/// exactly one DI scope and disposes it exactly once — on the success, failure, AND timeout
@@ -24,7 +24,9 @@ public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null)
/// <summary>Scope disposals observed so far (double-disposal is counted twice, deliberately).</summary>
public int ScopesDisposed => Volatile.Read(ref _scopesDisposed);
/// <inheritdoc />
/// <summary>Resolves the site event logger and this scope factory; every other service resolves to <c>null</c>.</summary>
/// <param name="serviceType">The requested service type.</param>
/// <returns>The matching service, or <c>null</c> when unsupported.</returns>
public object? GetService(Type serviceType)
{
if (serviceType == typeof(ISiteEventLogger)) return logger;
@@ -32,7 +34,8 @@ public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null)
return null;
}
/// <inheritdoc />
/// <summary>Creates a counted scope whose disposal is also counted.</summary>
/// <returns>A spy scope.</returns>
public IServiceScope CreateScope()
{
Interlocked.Increment(ref _scopesCreated);
@@ -41,8 +44,10 @@ public sealed class ScopeSpyServiceProvider(ISiteEventLogger? logger = null)
private sealed class SpyScope(ScopeSpyServiceProvider owner) : IServiceScope
{
/// <summary>Gets the owning provider as the scope's service provider.</summary>
public IServiceProvider ServiceProvider => owner;
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose() => Interlocked.Increment(ref owner._scopesDisposed);
}
}
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
/// <summary>
/// Minimal <see cref="IOptionsMonitor{TOptions}"/> test double with a mutable
/// <see cref="Set"/> so a test can flip a value mid-run and assert the NEXT read observes it
/// — used by the WP3.2 hot-toggle tests
/// — used by the hot-toggle tests
/// (<c>SiteRuntimeOptions.PerRunScriptEvents</c>/<c>PerRunScriptEventScripts</c> are read via
/// <c>IOptionsMonitor.CurrentValue</c> per run specifically so they don't need a restart).
/// Avoids depending on <c>Microsoft.Extensions.Configuration</c>'s reload-token plumbing,
@@ -15,15 +15,20 @@ public sealed class TestOptionsMonitor<T>(T initial) : IOptionsMonitor<T>
{
private T _current = initial;
/// <inheritdoc />
/// <summary>Gets the current value, as most recently set by <see cref="Set"/>.</summary>
public T CurrentValue => _current;
/// <inheritdoc />
/// <summary>Returns the current value regardless of the requested name.</summary>
/// <param name="name">The named-options key, ignored by this double.</param>
/// <returns>The current value.</returns>
public T Get(string? name) => _current;
/// <inheritdoc />
/// <summary>Change notifications are not supported by this double; no registration is made.</summary>
/// <param name="listener">The change listener, ignored by this double.</param>
/// <returns>Always <see langword="null"/>.</returns>
public IDisposable? OnChange(Action<T, string?> listener) => null;
/// <summary>Replaces the current value, observed by the next <see cref="CurrentValue"/> read.</summary>
/// <param name="value">The new value.</param>
public void Set(T value) => _current = value;
}
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// </summary>
public class OperationTrackingOptionsValidatorTests
{
/// <summary>Verifies that default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -18,6 +19,7 @@ public class OperationTrackingOptionsValidatorTests
Assert.True(validator.Validate(null, new OperationTrackingOptions()).Succeeded);
}
/// <summary>Verifies that empty connection string fails.</summary>
[Fact]
public void EmptyConnectionString_Fails()
{
@@ -28,6 +30,7 @@ public class OperationTrackingOptionsValidatorTests
f => f.Contains(nameof(OperationTrackingOptions.ConnectionString), StringComparison.Ordinal));
}
/// <summary>Verifies that zero retention days fails.</summary>
[Fact]
public void ZeroRetentionDays_Fails()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// <summary>
/// LocalDb Phase 1 (Task 2) — the <c>OperationTracking</c> DDL is extracted out of
/// LocalDb Phase 1 — the <c>OperationTracking</c> DDL is extracted out of
/// <see cref="OperationTrackingStore"/> so it can also be applied from the Host's
/// <c>AddZbLocalDb</c> onReady callback, which owns table creation for the consolidated
/// site database. These tests pin the extracted schema against the shape the store has
@@ -35,6 +35,7 @@ public class OperationTrackingSchemaTests
return cols;
}
/// <summary>Verifies that applying the schema creates the table with the expected columns.</summary>
[Fact]
public void Apply_CreatesTableWithExpectedColumns()
{
@@ -52,6 +53,7 @@ public class OperationTrackingSchemaTests
cols.Select(c => c.Name));
}
/// <summary>Verifies that the schema uses a text primary key.</summary>
[Fact]
public void Apply_UsesTextPrimaryKey()
{
@@ -66,6 +68,7 @@ public class OperationTrackingSchemaTests
Assert.Equal("TEXT", pk.Type);
}
/// <summary>Verifies that the schema has no blob columns.</summary>
[Fact]
public void Apply_HasNoBlobColumns()
{
@@ -80,6 +83,7 @@ public class OperationTrackingSchemaTests
c => c.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that applying the schema creates the expected indexes.</summary>
[Fact]
public void Apply_CreatesExpectedIndexes()
{
@@ -100,6 +104,7 @@ public class OperationTrackingSchemaTests
indexes);
}
/// <summary>Verifies that applying the schema repeatedly is idempotent.</summary>
[Fact]
public void Apply_IsIdempotent()
{
@@ -111,6 +116,7 @@ public class OperationTrackingSchemaTests
Assert.Equal(13, TableInfo(conn, "OperationTracking").Count);
}
/// <summary>Verifies that applying the schema adds the SourceNode column to a legacy table that lacks it.</summary>
[Fact]
public void Apply_AddsSourceNodeToLegacyTableMissingIt()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the
/// Schema + behaviour tests for the
/// site-local <see cref="OperationTrackingStore"/>.
/// </summary>
/// <remarks>
@@ -36,6 +36,7 @@ public class OperationTrackingStoreTests : IDisposable
private readonly List<ServiceProvider> _providers = [];
private readonly List<string> _tempFiles = [];
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
foreach (var provider in _providers)
@@ -102,6 +103,7 @@ public class OperationTrackingStoreTests : IDisposable
return connection;
}
/// <summary>Verifies that constructing the store creates the operation tracking schema on first use.</summary>
[Fact]
public void Constructor_CreatesOperationTracking_SchemaOnFirstUse()
{
@@ -136,6 +138,7 @@ public class OperationTrackingStoreTests : IDisposable
}
}
/// <summary>Verifies that initialization creates the operation tracking table with a SourceNode column.</summary>
[Fact]
public void Initialize_creates_OperationTracking_with_SourceNode_column()
{
@@ -202,6 +205,8 @@ public class OperationTrackingStoreTests : IDisposable
private OperationTrackingStore CreateStoreOver(string databasePath)
=> new(CreateLocalDb(databasePath), NullLogger<OperationTrackingStore>.Instance);
/// <summary>Verifies that initialization adds the SourceNode column to a pre-existing schema.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
{
@@ -255,6 +260,8 @@ public class OperationTrackingStoreTests : IDisposable
}
}
/// <summary>Verifies that recording an enqueue persists the source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordEnqueueAsync_persists_SourceNode()
{
@@ -275,6 +282,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal("node-a", snapshot!.SourceNode);
}
/// <summary>Verifies that recording an enqueue persists a null source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordEnqueueAsync_persists_null_SourceNode()
{
@@ -295,6 +304,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Null(snapshot!.SourceNode);
}
/// <summary>Verifies that recording an enqueue inserts a submitted row with a retry count of zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero()
{
@@ -326,6 +337,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(DateTimeKind.Utc, snapshot.UpdatedAtUtc.Kind);
}
/// <summary>Verifies that a duplicate enqueue is a no-op and the first write wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins()
{
@@ -346,6 +359,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(0, snapshot.RetryCount);
}
/// <summary>Verifies that recording an attempt advances the status and retry count on a non-terminal row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow()
{
@@ -374,6 +389,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.True(snapshot.UpdatedAtUtc >= snapshot.CreatedAtUtc);
}
/// <summary>Verifies that recording an attempt on a terminal row is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordAttemptAsync_OnTerminalRow_IsNoOp()
{
@@ -409,6 +426,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.NotNull(afterLate.TerminalAtUtc);
}
/// <summary>Verifies that recording a terminal outcome flips the row to terminal and sets the terminal timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet()
{
@@ -435,6 +454,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(503, snapshot.HttpStatus);
}
/// <summary>Verifies that getting the status of an unknown operation returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetStatusAsync_Unknown_ReturnsNull()
{
@@ -447,6 +468,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Null(snapshot);
}
/// <summary>Verifies that getting the status returns the latest snapshot after multiple attempts.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts()
{
@@ -466,6 +489,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(504, snapshot.HttpStatus);
}
/// <summary>Verifies that purging removes old terminal rows while keeping recent and non-terminal rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal()
{
@@ -510,8 +535,10 @@ public class OperationTrackingStoreTests : IDisposable
Assert.NotNull(await store.GetStatusAsync(cId)); // kept (non-terminal)
}
// ── Site Call Audit #22: ReadChangedSinceAsync (reconciliation pull) ───
// ── Site call audit: ReadChangedSinceAsync (reconciliation pull) ───
/// <summary>Verifies that the changed-since read returns rows at or after the cursor, oldest first.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSinceAsync_ReturnsRowsAtOrAfterCursor_OldestFirst()
{
@@ -546,6 +573,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.True(result[0].UpdatedAtUtc <= result[1].UpdatedAtUtc);
}
/// <summary>Verifies that the changed-since read from the minimum timestamp returns all rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSinceAsync_FromMinValue_ReturnsAllRows()
{
@@ -560,6 +589,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(2, result.Count);
}
/// <summary>Verifies that the changed-since read is capped to the batch size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSinceAsync_IsBatchCapped()
{
@@ -587,6 +618,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(ids[2], result[2].TrackedOperationId);
}
/// <summary>Verifies that the changed-since read maps a tracking row onto the site call operational record.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSinceAsync_MapsTrackingRowOntoSiteCallOperational()
{
@@ -647,6 +680,8 @@ public class OperationTrackingStoreTests : IDisposable
// ── 2026-09-06: writes off the script thread (single-writer queue) ─────
// Design: docs/plans/2026-09-06-tracking-store-async-writes-design.md
/// <summary>Verifies that a write returns while another connection holds the write lock and lands once it is released.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_ReturnsWhileAnotherConnectionHoldsTheWriteLock_AndLandsOnceReleased()
{
@@ -684,6 +719,8 @@ public class OperationTrackingStoreTests : IDisposable
}
}
/// <summary>Verifies that reads see every write queued before them, in order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reads_SeeEveryWriteQueuedBeforeThem_InOrder()
{
@@ -714,6 +751,8 @@ public class OperationTrackingStoreTests : IDisposable
}
}
/// <summary>Verifies that async dispose drains queued writes before closing the connection.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_DrainsQueuedWrites_BeforeClosingTheConnection()
{
@@ -731,6 +770,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(200L, (long)count.ExecuteScalar()!);
}
/// <summary>Verifies that writing after dispose throws an object-disposed exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_AfterDispose_ThrowsObjectDisposed()
{
@@ -740,12 +781,14 @@ public class OperationTrackingStoreTests : IDisposable
store.RecordEnqueueAsync(TrackedOperationId.New(), "ApiCallCached", null, null, null, null));
}
// ── SiteRuntime-024: read/write split + sync-safe Dispose ──────────────
// ── Read/write split + sync-safe Dispose ──────────────
/// <summary>Verifies that concurrent reads do not block on an in-flight write.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR024_ConcurrentReads_DoNotBlockOnInFlightWrite()
{
// Regression test for SiteRuntime-024 (perf half). Pre-fix, every
// Regression test for the read/write split (perf half). Pre-fix, every
// GetStatusAsync took the same _gate as RecordTerminalAsync, so a single
// long-running write would queue up every concurrent status query. After
// the fix, reads open a fresh SqliteConnection per call and don't take
@@ -786,10 +829,12 @@ public class OperationTrackingStoreTests : IDisposable
}
}
/// <summary>Verifies that a synchronous dispose from a fresh thread does not deadlock.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR024_SyncDispose_DoesNotDeadlock_WhenInvokedFromFreshThread()
{
// Regression test for SiteRuntime-024 (deadlock half). Pre-fix, Dispose
// Regression test for the sync-safe Dispose (deadlock half). Pre-fix, Dispose
// bridged to async via DisposeAsyncCore().AsTask().GetAwaiter().GetResult()
// — sync-over-async on a SemaphoreSlim can deadlock under a non-reentrant
// SyncContext (host shutdown continuations). Post-fix, Dispose runs
@@ -828,6 +873,8 @@ public class OperationTrackingStoreTests : IDisposable
Assert.True(await disposeReturned.Task);
}
/// <summary>Verifies that async dispose does not deadlock and is idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SR024_AsyncDispose_DoesNotDeadlock_AndIsIdempotent()
{
@@ -851,8 +898,10 @@ public class OperationTrackingStoreTests : IDisposable
await store.DisposeAsync();
}
// ── Task 24: pin the inclusive (>=) reconciliation cursor boundary ──
// ── Pin the inclusive (>=) reconciliation cursor boundary ──
/// <summary>Verifies that the changed-since boundary timestamp is inclusive and an upsert on a newer status absorbs duplicates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSince_BoundaryTimestamp_IsInclusive_UpsertOnNewerAbsorbsDuplicates()
{
@@ -895,7 +944,7 @@ public class OperationTrackingStoreTests : IDisposable
Assert.Equal(2, rows.Count);
}
// ── Task 15: composite (UpdatedAtUtc, TrackedOperationId) keyset ──
// ── Composite (UpdatedAtUtc, TrackedOperationId) keyset ──
/// <summary>
/// Seeds <c>batchSize + 2</c> rows that all share ONE <c>UpdatedAtUtc</c> and
@@ -904,6 +953,7 @@ public class OperationTrackingStoreTests : IDisposable
/// returns the REMAINING rows with no overlap — the stall the plain inclusive
/// <c>&gt;=</c> resume can never escape.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSince_CompositeKeyset_AdvancesPastSharedTimestamp()
{
@@ -945,6 +995,7 @@ public class OperationTrackingStoreTests : IDisposable
/// shared timestamp re-reads the same page (the exact stall the keyset fixes) —
/// an older central that never sends <c>after_id</c> is byte-for-byte unaffected.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadChangedSince_WithoutKeyset_ReReadsSamePage_LegacyPreserved()
{
@@ -984,6 +1035,8 @@ public class OperationTrackingStoreTests : IDisposable
cmd.ExecuteNonQuery();
return boundary;
}
/// <summary>Verifies that a synchronous dispose does not close the connection while the writer gate is held.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ORCH1_SyncDisposeDoesNotCloseConnectionWhileWriterGateIsHeld()
{