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