refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,875 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
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.
/// </summary>
public class AlarmActorTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ScriptCompilationService _compilationService;
public AlarmActorTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions();
}
void IDisposable.Dispose()
{
Shutdown();
}
[Fact]
public void AlarmActor_ValueMatch_ActivatesOnMatch()
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Send value that matches
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
// Instance Actor should receive AlarmStateChanged
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
Assert.Equal("HighTemp", msg.AlarmName);
}
[Fact]
public void AlarmActor_ValueMatch_ClearsOnNonMatch()
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Activate
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
var activateMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, activateMsg.State);
// Clear
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow));
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Normal, clearMsg.State);
}
[Fact]
public void AlarmActor_RangeViolation_ActivatesOutsideRange()
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "TempRange",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
PriorityLevel = 2
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"TempRange", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Value within range -- no alarm
alarm.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// Value outside range -- alarm activates
alarm.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
}
[Fact]
public void AlarmActor_RangeViolation_ClearsWhenBackInRange()
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "TempRange",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
PriorityLevel = 2
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"TempRange", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Activate
alarm.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
// Clear
alarm.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "75", "Good", DateTimeOffset.UtcNow));
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Normal, clearMsg.State);
}
[Fact]
public void AlarmActor_IgnoresUnmonitoredAttributes()
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "TempAlarm",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Send change for a different attribute
alarm.Tell(new AttributeValueChanged(
"Pump1", "Pressure", "Pressure", "100", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive()
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "TempAlarm",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// First trigger
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
// Second trigger with same value -- should NOT re-trigger
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_StartsNormal_OnRestart()
{
// Per design: on restart, alarm starts normal, re-evaluates from incoming values
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "RestartAlarm",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"RestartAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// A "Good" value should not trigger since alarm starts Normal
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_NoClearScript_OnDeactivation()
{
// WP-16: On clear, NO script is executed. Only on activate.
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "ClearTest",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"ClearTest", "Pump1", instanceProbe.Ref, alarmConfig,
null, // no on-trigger script
_sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Activate
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
// Clear -- should send state change but no script execution
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow));
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Normal, clearMsg.State);
// No additional messages (no script execution side effects)
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
// ── RateOfChange ───────────────────────────────────────────────────────
/// <summary>
/// Builds a RateOfChange config JSON with the given threshold (units/sec),
/// window (seconds), and direction. Used by the rate-of-change tests.
/// </summary>
private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
$"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";
private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "RocAlarm",
TriggerType = "RateOfChange",
TriggerConfiguration = config,
PriorityLevel = 3
};
return ActorOf(Props.Create(() => new AlarmActor(
"RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
}
private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
new("Pump1", "Pressure", "Pressure",
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
"Good", ts);
[Fact]
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
{
var instanceProbe = CreateTestProbe();
// 50 units/sec threshold, 2 sec window
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
// First sample establishes the window baseline; needs ≥2 samples to compute a rate.
alarm.Tell(PressureSample(0, t0));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// 100 over 1 sec = 100 units/sec > 50 threshold → activate
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
}
[Fact]
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
{
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(100, t0));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
}
[Fact]
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
{
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(0, t0));
// 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
alarm.Tell(PressureSample(10, t0.AddSeconds(1)));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
{
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(100, t0));
// -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
{
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(0, t0));
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
{
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(100, t0));
alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
}
[Fact]
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
{
// The evaluator needs at least two samples in the window to compute a rate.
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(1000, t0));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
{
// 1-second window. Sample at t=0 with value 0 should fall out before the
// sample at t=3, so the in-window history is just the two recent samples
// (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
// the threshold, so no alarm even though the long-term delta is huge.
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
alarm.Tell(PressureSample(0, t0));
alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
alarm.Tell(PressureSample(100, t0.AddSeconds(3)));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
{
var instanceProbe = CreateTestProbe();
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Spike: activate
alarm.Tell(PressureSample(0, t0));
alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, activate.State);
// Now sample again well past the 1-second window with only a tiny change
// → rate falls below threshold → clears.
alarm.Tell(PressureSample(101, t0.AddSeconds(3)));
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Normal, clear.State);
}
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
[Fact]
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
{
// Old configs used "attribute" / "value" before the canonical names landed.
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "Legacy",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
}
[Fact]
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
{
// matchValue "!=Good" means: alarm when Status is anything other than Good.
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "Inverted",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Status=Critical (not "Good") → alarm activates
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, activate.State);
// Status=Good → alarm clears
alarm.Tell(new AttributeValueChanged(
"Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Normal, clear.State);
}
[Fact]
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
{
// Older configs used "low" / "high" instead of the current "min" / "max".
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "Legacy",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
// Within range → no alarm
alarm.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// Outside range → activate
alarm.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
}
// ── HiLo ───────────────────────────────────────────────────────────────
/// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
{
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "TempAlarm",
TriggerType = "HiLo",
TriggerConfiguration = config,
PriorityLevel = priority
};
return ActorOf(Props.Create(() => new AlarmActor(
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
}
private static AttributeValueChanged TempSample(double value) =>
new("Pump1", "Temperature", "Temperature",
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
"Good", DateTimeOffset.UtcNow);
[Fact]
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(50)); // normal band — no emit
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
alarm.Tell(TempSample(85)); // crosses Hi but not HiHi
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
Assert.Equal(AlarmLevel.High, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(85));
var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.High, first.Level);
alarm.Tell(TempSample(120)); // crosses HiHi
var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, second.State);
Assert.Equal(AlarmLevel.HighHigh, second.Level);
}
[Fact]
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(120));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Active, msg.State);
Assert.Equal(AlarmLevel.High, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(85));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
alarm.Tell(TempSample(50)); // back to normal
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmState.Normal, clear.State);
Assert.Equal(AlarmLevel.None, clear.Level);
}
[Fact]
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(8));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.Low, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(-5));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.LowLow, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
{
var instanceProbe = CreateTestProbe();
// Alarm-level priority is 500; HiHi explicitly bumps to 900.
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);
alarm.Tell(TempSample(85));
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.High, hi.Level);
Assert.Equal(600, hi.Priority);
alarm.Tell(TempSample(120));
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
Assert.Equal(900, hiHi.Priority);
}
[Fact]
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);
alarm.Tell(TempSample(85));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(432, msg.Priority);
}
[Fact]
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
{
// Only HiHi is configured — values that would have hit a Lo or Hi band
// (in a fully-configured alarm) are inside the implicit normal band here.
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(-1000));
alarm.Tell(TempSample(95));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
alarm.Tell(TempSample(110));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
{
// When the value exactly equals the boundary, the most-severe matching
// band wins. value == HiHi → HighHigh (not High).
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(100));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
{
// Two updates that resolve to the same level should produce exactly one
// AlarmStateChanged — the second is a no-op.
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(85));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
alarm.Tell(TempSample(90)); // still in the Hi band
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
[Fact]
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
{
// Validation flags this as a warning at design time; runtime behavior
// is "evaluates to None forever".
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature""}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(99999));
alarm.Tell(TempSample(-99999));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
// ── HiLo hysteresis ────────────────────────────────────────────────────
[Fact]
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
{
// HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
// until the value drops below 95 — at 96 it should still be HighHigh.
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(120));
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.HighHigh, entered.Level);
// 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
alarm.Tell(TempSample(96));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(120));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
// 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
alarm.Tell(TempSample(94));
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.High, msg.Level);
}
[Fact]
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
{
// Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(85));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
alarm.Tell(TempSample(78)); // 78 > 75 → still High
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.None, clear.Level);
}
[Fact]
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
{
// Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(8));
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.Low, entered.Level);
alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
alarm.Tell(TempSample(14)); // 14 > 13 → clears
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.None, clear.Level);
}
[Fact]
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
{
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(85));
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal("Coolant warm — check tank", hi.Message);
alarm.Tell(TempSample(120));
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal("Coolant critical — shut down", hiHi.Message);
// Clearing back to normal carries an empty message.
alarm.Tell(TempSample(50));
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(string.Empty, clear.Message);
}
[Fact]
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
{
// Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
var instanceProbe = CreateTestProbe();
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
var alarm = SpawnHiLoAlarm(config, instanceProbe);
alarm.Tell(TempSample(85));
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
// Despite the large deadband, escalation uses the activation threshold (100).
alarm.Tell(TempSample(101));
var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
}
[Fact]
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
{
// ParseEvalConfig falls back to a safe default on JSON failure; the actor
// should accept messages without throwing and just never trigger.
var alarmConfig = new ResolvedAlarm
{
CanonicalName = "Bad",
TriggerType = "ValueMatch",
TriggerConfiguration = "{not valid json",
PriorityLevel = 1
};
var instanceProbe = CreateTestProbe();
var alarm = ActorOf(Props.Create(() => new AlarmActor(
"Bad", "Pump1", instanceProbe.Ref, alarmConfig,
null, _sharedLibrary, _options,
NullLogger<AlarmActor>.Instance)));
alarm.Tell(new AttributeValueChanged(
"Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
}
@@ -0,0 +1,319 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Tests for DeploymentManagerActor: startup from SQLite, staggered batching,
/// lifecycle commands, and supervision strategy.
/// </summary>
public class DeploymentManagerActorTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerActorTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null)
{
options ??= new SiteRuntimeOptions();
return ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null, // no stream manager in tests
options,
NullLogger<DeploymentManagerActor>.Instance)));
}
private static string MakeConfigJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
]
};
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Builds a config carrying a single callable (no-trigger) script that
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
/// to be routed end-to-end through the Instance/Script/ScriptExecution actors.
/// </summary>
private static string MakeConfigWithScriptJson(string instanceName, string scriptName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
],
Scripts =
[
new ResolvedScript { CanonicalName = scriptName, Code = "return 7;" }
]
};
return JsonSerializer.Serialize(config);
}
[Fact]
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
{
// Pre-populate SQLite with deployed configs
await _storage.StoreDeployedConfigAsync("Pump1", MakeConfigJson("Pump1"), "d1", "h1", true);
await _storage.StoreDeployedConfigAsync("Pump2", MakeConfigJson("Pump2"), "d2", "h2", true);
var actor = CreateDeploymentManager(
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 });
// Allow time for async startup (load configs + create actors)
await Task.Delay(2000);
// Verify by deploying -- if actors already exist, we'd get a warning
// Instead, verify by checking we can send lifecycle commands
actor.Tell(new DisableInstanceCommand("cmd-1", "Pump1", DateTimeOffset.UtcNow));
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
Assert.Equal("Pump1", response.InstanceUniqueName);
}
[Fact]
public async Task DeploymentManager_SkipsDisabledInstances_OnStartup()
{
await _storage.StoreDeployedConfigAsync("Active1", MakeConfigJson("Active1"), "d1", "h1", true);
await _storage.StoreDeployedConfigAsync("Disabled1", MakeConfigJson("Disabled1"), "d2", "h2", false);
var actor = CreateDeploymentManager(
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 });
await Task.Delay(2000);
// The disabled instance should NOT have an actor running
// Try to disable it -- it should succeed (no actor to stop, but SQLite update works)
actor.Tell(new DisableInstanceCommand("cmd-2", "Disabled1", DateTimeOffset.UtcNow));
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
}
[Fact]
public async Task DeploymentManager_StaggeredBatchCreation()
{
// Create more instances than the batch size
for (int i = 0; i < 5; i++)
{
var name = $"Batch{i}";
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
}
// Use a small batch size to force multiple batches
var actor = CreateDeploymentManager(
new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 });
// Wait for all batches to complete (3 batches with 50ms delay = ~150ms + processing)
await Task.Delay(3000);
// Verify all instances are running by disabling them
for (int i = 0; i < 5; i++)
{
actor.Tell(new DisableInstanceCommand($"cmd-{i}", $"Batch{i}", DateTimeOffset.UtcNow));
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
}
}
[Fact]
public async Task DeploymentManager_Deploy_CreatesNewInstance()
{
var actor = CreateDeploymentManager();
await Task.Delay(500); // Wait for empty startup
actor.Tell(new DeployInstanceCommand(
"dep-100", "NewPump", "sha256:xyz", MakeConfigJson("NewPump"), "admin", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status);
Assert.Equal("NewPump", response.InstanceUniqueName);
}
[Fact]
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
{
var actor = CreateDeploymentManager();
await Task.Delay(500);
// Deploy
actor.Tell(new DeployInstanceCommand(
"dep-200", "LifecyclePump", "sha256:abc",
MakeConfigJson("LifecyclePump"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
// Wait for the async deploy persistence (PipeTo) to complete
await Task.Delay(1000);
// Disable
actor.Tell(new DisableInstanceCommand("cmd-d1", "LifecyclePump", DateTimeOffset.UtcNow));
var disableResp = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(disableResp.Success);
// Verify disabled in storage
await Task.Delay(500);
var configs = await _storage.GetAllDeployedConfigsAsync();
var pump = configs.FirstOrDefault(c => c.InstanceUniqueName == "LifecyclePump");
Assert.NotNull(pump);
Assert.False(pump.IsEnabled);
// Delete
actor.Tell(new DeleteInstanceCommand("cmd-del1", "LifecyclePump", DateTimeOffset.UtcNow));
var deleteResp = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(deleteResp.Success);
// Verify removed from storage
await Task.Delay(500);
configs = await _storage.GetAllDeployedConfigsAsync();
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "LifecyclePump");
}
// ── DeploymentManager-006: query-the-site-before-redeploy ──
[Fact]
public async Task DeploymentStateQuery_DeployedInstance_ReturnsAppliedIdentity()
{
// A deployed instance must report its currently-applied deployment ID
// and revision hash so central can reconcile before a re-deploy.
await _storage.StoreDeployedConfigAsync(
"QueriedPump", MakeConfigJson("QueriedPump"), "dep-applied", "sha256:applied", true);
var actor = CreateDeploymentManager();
await Task.Delay(2000); // allow startup to load configs
actor.Tell(new DeploymentStateQueryRequest("corr-q1", "QueriedPump", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStateQueryResponse>(TimeSpan.FromSeconds(5));
Assert.Equal("corr-q1", response.CorrelationId);
Assert.Equal("QueriedPump", response.InstanceUniqueName);
Assert.True(response.IsDeployed);
Assert.Equal("dep-applied", response.AppliedDeploymentId);
Assert.Equal("sha256:applied", response.AppliedRevisionHash);
}
[Fact]
public async Task DeploymentStateQuery_UnknownInstance_ReturnsNotDeployed()
{
// An instance the site has never received a deployment for must report
// IsDeployed=false with null applied identity.
var actor = CreateDeploymentManager();
await Task.Delay(500);
actor.Tell(new DeploymentStateQueryRequest("corr-q2", "NeverDeployed", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStateQueryResponse>(TimeSpan.FromSeconds(5));
Assert.Equal("corr-q2", response.CorrelationId);
Assert.False(response.IsDeployed);
Assert.Null(response.AppliedDeploymentId);
Assert.Null(response.AppliedRevisionHash);
}
[Fact]
public void DeploymentManager_SupervisionStrategy_ResumesOnException()
{
var actor = CreateDeploymentManager();
// The actor exists and is responsive -- supervision is configured
actor.Tell(new DeployInstanceCommand(
"dep-sup", "SupervisedPump", "sha256:sup",
MakeConfigJson("SupervisedPump"), "admin", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status);
}
// ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
[Fact]
public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
{
// A RouteToCallRequest carrying a ParentExecutionId (the inbound
// request's ExecutionId) must be mapped to a ScriptCallRequest and
// routed end-to-end through the Instance/Script/ScriptExecution actors.
// The additive ParentExecutionId field must not break that routing.
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
actor.Tell(new DeployInstanceCommand(
"dep-route", "RoutedPump", "sha256:route",
MakeConfigWithScriptJson("RoutedPump", "DoWork"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000); // let the InstanceActor + ScriptActor spin up
var parentExecutionId = Guid.NewGuid();
actor.Tell(new RouteToCallRequest(
"route-corr-1", "RoutedPump", "DoWork",
Parameters: null, DateTimeOffset.UtcNow, ParentExecutionId: parentExecutionId));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("route-corr-1", response.CorrelationId);
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
}
[Fact]
public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
{
// A routed call with no ParentExecutionId (e.g. the Central UI sandbox)
// is the additive-default path — it must route exactly as before.
var actor = CreateDeploymentManager();
await Task.Delay(500);
actor.Tell(new DeployInstanceCommand(
"dep-route2", "RoutedPump2", "sha256:route2",
MakeConfigWithScriptJson("RoutedPump2", "DoWork"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000);
// No ParentExecutionId argument — exercises the additive `= null` default.
actor.Tell(new RouteToCallRequest(
"route-corr-2", "RoutedPump2", "DoWork",
Parameters: null, DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("route-corr-2", response.CorrelationId);
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
}
}
@@ -0,0 +1,115 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression test for SiteRuntime-015 — <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.
/// </summary>
public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerLoggerFactoryTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private static string MakeConfigJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
]
};
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Counts <see cref="ILoggerFactory.CreateLogger"/> calls and records whether
/// the factory was disposed. A passing test proves the single injected factory
/// is the one used for every Instance Actor.
/// </summary>
private sealed class CountingLoggerFactory : ILoggerFactory
{
public int CreateLoggerCalls;
public bool Disposed;
public ILogger CreateLogger(string categoryName)
{
Interlocked.Increment(ref CreateLoggerCalls);
return NullLogger.Instance;
}
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() => Disposed = true;
}
[Fact]
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
{
// Pre-populate several enabled instances so startup creates multiple
// Instance Actors.
const int instanceCount = 6;
for (int i = 0; i < instanceCount; i++)
{
var name = $"Inst{i}";
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
}
var loggerFactory = new CountingLoggerFactory();
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 5 },
NullLogger<DeploymentManagerActor>.Instance,
null,
null,
null,
null,
loggerFactory)));
// Allow async startup (load configs + staggered creation).
await Task.Delay(2000);
// Every Instance Actor logger must come from the single injected factory.
// Before the fix, each CreateInstanceActor allocated its own LoggerFactory,
// so the injected factory would never be touched (CreateLoggerCalls == 0).
Assert.Equal(instanceCount, loggerFactory.CreateLoggerCalls);
}
}
@@ -0,0 +1,248 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
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).
/// </summary>
public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
{
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerMediumFindingsTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-medium-test-{Guid.NewGuid():N}.db");
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private SiteStorageService NewStorage(string connectionString)
=> new(connectionString, NullLogger<SiteStorageService>.Instance);
private IActorRef CreateDeploymentManager(SiteStorageService storage, IActorRef? dclManager = null)
{
return ActorOf(Props.Create(() => new DeploymentManagerActor(
storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions(),
NullLogger<DeploymentManagerActor>.Instance,
dclManager,
null,
null,
null)));
}
private static string MakeConfigJsonWithConnection(
string instanceName, string endpoint, int failoverRetryCount)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
],
Connections = new Dictionary<string, ConnectionConfig>
{
["Conn1"] = new ConnectionConfig
{
Protocol = "Custom",
ConfigurationJson = $"{{\"endpoint\":\"{endpoint}\"}}",
FailoverRetryCount = failoverRetryCount
}
}
};
return JsonSerializer.Serialize(config);
}
private static string MakeConfigJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
]
};
return JsonSerializer.Serialize(config);
}
/// <summary>
/// SiteRuntime-005: 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>
[Fact]
public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess()
{
// A connection string pointing at an unwritable path makes every storage
// write throw, so StoreDeployedConfigAsync fails.
var badPath = Path.Combine(
Path.GetTempPath(), $"no-such-dir-{Guid.NewGuid():N}", "site.db");
var storage = NewStorage($"Data Source={badPath}");
var actor = CreateDeploymentManager(storage);
await Task.Delay(500); // empty startup
actor.Tell(new DeployInstanceCommand(
"dep-fail", "FailPump", "h1", MakeConfigJson("FailPump"), "admin", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("FailPump", response.InstanceUniqueName);
Assert.Equal(DeploymentStatus.Failed, response.Status);
Assert.False(string.IsNullOrEmpty(response.ErrorMessage));
}
/// <summary>
/// SiteRuntime-005: 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>
[Fact]
public async Task Deploy_Success_ReportsSuccessAndPersistsConfig()
{
var storage = NewStorage($"Data Source={_dbFile}");
await storage.InitializeAsync();
var actor = CreateDeploymentManager(storage);
await Task.Delay(500);
actor.Tell(new DeployInstanceCommand(
"dep-ok", "OkPump", "h1", MakeConfigJson("OkPump"), "admin", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal(DeploymentStatus.Success, response.Status);
// By the time Success is reported, the config must be durable.
var configs = await storage.GetAllDeployedConfigsAsync();
Assert.Contains(configs, c => c.InstanceUniqueName == "OkPump");
}
/// <summary>
/// SiteRuntime-010: when a redeployment changes a connection's configuration
/// (here the failover retry count and endpoint), the Deployment Manager must
/// re-issue a <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand"/>
/// so the DCL adopts the new configuration rather than keeping the stale one.
/// </summary>
[Fact]
public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesCreateCommand()
{
var storage = NewStorage($"Data Source={_dbFile}");
await storage.InitializeAsync();
var dcl = CreateTestProbe();
var actor = CreateDeploymentManager(storage, dcl.Ref);
await Task.Delay(500);
// Initial deploy with one connection.
actor.Tell(new DeployInstanceCommand(
"dep-c1", "ConnPump", "h1",
MakeConfigJsonWithConnection("ConnPump", "opc.tcp://host-a:4840", 3),
"admin", DateTimeOffset.UtcNow));
var firstCreate = dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
TimeSpan.FromSeconds(5));
Assert.Equal("Conn1", firstCreate.ConnectionName);
Assert.Equal(3, firstCreate.FailoverRetryCount);
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(500);
// Redeploy with a CHANGED connection configuration.
actor.Tell(new DeployInstanceCommand(
"dep-c2", "ConnPump", "h2",
MakeConfigJsonWithConnection("ConnPump", "opc.tcp://host-b:4840", 7),
"admin", DateTimeOffset.UtcNow));
// The DCL must receive a fresh create command reflecting the new config.
var secondCreate = dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
TimeSpan.FromSeconds(10));
Assert.Equal("Conn1", secondCreate.ConnectionName);
Assert.Equal(7, secondCreate.FailoverRetryCount);
}
/// <summary>
/// SiteRuntime-010: an unchanged connection configuration must still be skipped —
/// re-sending an identical create command on every deploy is wasteful.
/// </summary>
[Fact]
public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand()
{
var storage = NewStorage($"Data Source={_dbFile}");
await storage.InitializeAsync();
var dcl = CreateTestProbe();
var actor = CreateDeploymentManager(storage, dcl.Ref);
await Task.Delay(500);
var json = MakeConfigJsonWithConnection("StablePump", "opc.tcp://host-a:4840", 3);
actor.Tell(new DeployInstanceCommand(
"dep-s1", "StablePump", "h1", json, "admin", DateTimeOffset.UtcNow));
dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
TimeSpan.FromSeconds(5));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(500);
// Redeploy with the IDENTICAL connection configuration.
actor.Tell(new DeployInstanceCommand(
"dep-s2", "StablePump", "h2", json, "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
// No further create command for an unchanged connection.
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(800));
}
/// <summary>
/// SiteRuntime-008: 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>
[Fact]
public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive()
{
var storage = NewStorage($"Data Source={_dbFile}");
await storage.InitializeAsync();
// Several shared scripts to compile during startup.
for (var i = 0; i < 5; i++)
{
await storage.StoreSharedScriptAsync(
$"Shared{i}", "return 1 + 1;", null, null);
}
await storage.StoreDeployedConfigAsync(
"StartupPump", MakeConfigJson("StartupPump"), "d1", "h1", true);
var actor = CreateDeploymentManager(storage);
await Task.Delay(2000);
// The instance loaded at startup must be operable — proves startup completed
// and the actor processed messages after the shared-script load.
actor.Tell(new DeploymentStateQueryRequest("corr-1", "StartupPump", DateTimeOffset.UtcNow));
var response = ExpectMsg<DeploymentStateQueryResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.IsDeployed);
}
}
@@ -0,0 +1,217 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for SiteRuntime-003: 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>
public class DeploymentManagerRedeployTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerRedeployTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-redeploy-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private IActorRef CreateDeploymentManager(ISiteHealthCollector? healthCollector = null)
{
return ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions(),
NullLogger<DeploymentManagerActor>.Instance,
null,
null,
healthCollector,
null)));
}
/// <summary>
/// Minimal fake that records the most recent deployed-instance count.
/// </summary>
private sealed class CountCapturingHealthCollector : ISiteHealthCollector
{
public int LastDeployedCount { get; private set; }
public void IncrementScriptError() { }
public void IncrementAlarmError() { }
public void IncrementDeadLetter() { }
public void IncrementSiteAuditWriteFailures() { }
public void IncrementAuditRedactionFailure() { }
public void UpdateSiteAuditBacklog(ZB.MOM.WW.ScadaBridge.Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
public void RemoveConnection(string connectionName) { }
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
public void SetInstanceCounts(int deployed, int enabled, int disabled) => LastDeployedCount = deployed;
public void SetParkedMessageCount(int count) { }
public void SetNodeHostname(string hostname) { }
public void SetClusterNodes(IReadOnlyList<NodeStatus> nodes) { }
public void SetActiveNode(bool isActive) { }
public bool IsActiveNode => true;
public SiteHealthReport CollectReport(string siteId) => throw new NotSupportedException();
}
private static string MakeConfigJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
]
};
return JsonSerializer.Serialize(config);
}
[Fact]
public async Task Redeploy_ExistingInstance_SucceedsWithoutNameCollision()
{
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
// Initial deploy.
actor.Tell(new DeployInstanceCommand(
"dep-1", "RedeployPump", "h1", MakeConfigJson("RedeployPump"), "admin", DateTimeOffset.UtcNow));
var first = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, first.Status);
await Task.Delay(500);
// Redeploy the same instance — must replace the existing actor cleanly.
actor.Tell(new DeployInstanceCommand(
"dep-2", "RedeployPump", "h2", MakeConfigJson("RedeployPump"), "admin", DateTimeOffset.UtcNow));
var second = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal(DeploymentStatus.Success, second.Status);
// The redeployed instance must still be operable (no orphaned/broken actor).
actor.Tell(new DisableInstanceCommand("cmd-1", "RedeployPump", DateTimeOffset.UtcNow));
var disable = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(disable.Success);
}
[Fact]
public async Task SR020_ThreeRapidDeploys_DoNotThrowInvalidActorNameException_LatestWins()
{
// Regression test for SiteRuntime-020. 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
// _instanceActors.TryGetValue==false and fell through to
// ApplyDeployment → CreateInstanceActor → Context.ActorOf, which threw
// InvalidActorNameException because the child name was still registered
// until Terminated fires. The supervisor's Stop directive then silently
// dropped the deploy, leaving the deployer waiting forever and the
// persistence Task.Run dangling. After the fix, _terminatingActorsByName
// tracks the in-flight terminator by name; the third deploy overwrites
// the buffered pending command (last-write-wins) and tells the displaced
// sender it was superseded.
var actor = CreateDeploymentManager();
await Task.Delay(500);
// Initial deploy — establishes the running instance.
actor.Tell(new DeployInstanceCommand(
"dep-1", "RapidPump", "h1", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow));
var first = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, first.Status);
await Task.Delay(200);
// 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.
var probe2 = CreateTestProbe();
var probe3 = CreateTestProbe();
actor.Tell(new DeployInstanceCommand(
"dep-2", "RapidPump", "h2", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow),
probe2.Ref);
actor.Tell(new DeployInstanceCommand(
"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
// predecessor finishes terminating.
var superseded = probe2.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("dep-2", superseded.DeploymentId);
Assert.Equal(DeploymentStatus.Failed, superseded.Status);
Assert.NotNull(superseded.ErrorMessage);
Assert.Contains("superseded", superseded.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
var winner = probe3.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("dep-3", winner.DeploymentId);
Assert.Equal(DeploymentStatus.Success, winner.Status);
// The instance must still be operable — proves no orphaned actor / no
// half-created child holding the name.
actor.Tell(new DisableInstanceCommand("cmd-1", "RapidPump", DateTimeOffset.UtcNow));
var disable = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
Assert.True(disable.Success);
}
[Fact]
public async Task Redeploy_ExistingInstance_DoesNotOverCountDeployedInstances()
{
var health = new CountCapturingHealthCollector();
var actor = CreateDeploymentManager(health);
await Task.Delay(500);
// Deploy once.
actor.Tell(new DeployInstanceCommand(
"dep-1", "CountPump", "h1", MakeConfigJson("CountPump"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(500);
// Redeploy several times.
for (var i = 2; i <= 4; i++)
{
actor.Tell(new DeployInstanceCommand(
$"dep-{i}", "CountPump", $"h{i}", MakeConfigJson("CountPump"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
await Task.Delay(500);
}
// Storage uses UPSERT — exactly one deployed config row should exist.
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs, c => c.InstanceUniqueName == "CountPump");
// The reported deployed count must be exactly 1 — a redeploy is an update,
// not a new instance, so the in-memory counter must not drift upward.
Assert.Equal(1, health.LastDeployedCount);
}
}
@@ -0,0 +1,171 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
/// PoisonPill self-stop after completion.
/// </summary>
public class ExecutionActorTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptCompilationService _compilationService;
public ExecutionActorTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose() => Shutdown();
private static Script<object?> CompileScript(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
=> new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };
// ── ScriptExecutionActor ──
[Fact]
public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
{
var compiled = CompileScript("return 7 * 6;");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Answer", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-1", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.Equal("corr-1", result.CorrelationId);
Assert.Equal(42, result.ReturnValue);
// The actor must PoisonPill itself once execution completes.
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
{
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Bad", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-2", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("corr-2", result.CorrelationId);
Assert.Contains("boom", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
{
// A long busy loop that observes the cancellation token so the
// 1-second timeout fires cooperatively.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Slow", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
replyTo.Ref, "corr-3", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
{
var compiled = CompileScript("return 1;");
var instanceActor = CreateTestProbe();
// ActorRefs.Nobody as replyTo — fire-and-forget execution.
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"FireForget", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
ActorRefs.Nobody, "corr-4", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
// ── AlarmExecutionActor ──
[Fact]
public void AlarmExecutionActor_Success_StopsAfterCompletion()
{
var compiled = CompileScript("return 0;");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(),
NullLogger.Instance)));
Watch(exec);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void AlarmExecutionActor_ScriptThrows_StillStops()
{
var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(),
NullLogger.Instance)));
Watch(exec);
// Even on a throwing on-trigger body, the actor must self-stop.
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
}
@@ -0,0 +1,187 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Reflection;
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
/// own live mutable <c>_attributes</c> dictionary by reference into the
/// <see cref="ScriptActor"/> / <see cref="AlarmActor"/> constructors.
///
/// Each child constructor runs on the child's own mailbox thread and seeds itself
/// by enumerating the dictionary it was given. The Instance Actor concurrently
/// mutates <c>_attributes</c> in <c>HandleAttributeValueChanged</c> /
/// <c>HandleTagValueUpdate</c>. <see cref="Dictionary{TKey,TValue}"/> is not safe
/// for concurrent read/write: if a child enumerates the shared live dictionary
/// while the Instance Actor inserts into it, the child constructor throws
/// <see cref="InvalidOperationException"/> ("collection was modified") — surfacing
/// as <c>ActorInitializationException</c> and stopping the child.
///
/// The fix: <c>CreateChildActors</c> snapshots <c>_attributes</c> once on the
/// Instance Actor thread (<c>new Dictionary&lt;,&gt;(_attributes)</c>) and hands
/// each child that private copy. This test asserts the isolation contract
/// directly and deterministically: every child's seed dictionary must be a
/// distinct object from the Instance Actor's live <c>_attributes</c>, while still
/// carrying the same point-in-time contents.
/// </summary>
public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly string _dbFile;
public InstanceActorChildAttributeRaceTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-race-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = 30
};
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private static FlattenedConfiguration BuildConfig(string instanceName)
=> new()
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "Pressure", Value = "12", DataType = "Int32" },
new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" }
],
Scripts =
[
new ResolvedScript
{
CanonicalName = "WorkerA", Code = "return 1;",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"AttributeName\":\"Temperature\"}"
},
new ResolvedScript
{
CanonicalName = "WorkerB", Code = "return 2;",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"AttributeName\":\"Pressure\"}"
}
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "ValueMatch",
TriggerConfiguration = "{}",
PriorityLevel = 1
}
]
};
/// <summary>Resolves the live actor instance behind a local <see cref="IActorRef"/>.</summary>
private static object GetActorInstance(IActorRef actorRef)
{
var cell = ((ActorRefWithCell)actorRef).Underlying;
// ActorCell exposes the actor instance via its internal Actor property.
var actorProp = cell.GetType().GetProperty(
"Actor", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
var instance = actorProp!.GetValue(cell);
Assert.NotNull(instance);
return instance!;
}
private static Dictionary<string, object?> GetPrivateAttributes(InstanceActor instance)
{
var field = typeof(InstanceActor).GetField(
"_attributes", BindingFlags.Instance | BindingFlags.NonPublic);
return (Dictionary<string, object?>)field!.GetValue(instance)!;
}
[Fact]
public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary()
{
const string instanceName = "RacePump";
var config = BuildConfig(instanceName);
var testRef = ActorOfAsTestActorRef<InstanceActor>(
Props.Create(() => new InstanceActor(
instanceName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance)),
"instance");
var instanceActor = testRef.UnderlyingActor;
var liveAttributes = GetPrivateAttributes(instanceActor);
// Sanity: the children were created.
Assert.Equal(2, instanceActor.ScriptActorCount);
Assert.Equal(1, instanceActor.AlarmActorCount);
// 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).
foreach (var name in new[] { "WorkerA", "WorkerB" })
{
var child = await Sys.ActorSelection(testRef.Path / $"script-{name}")
.ResolveOne(TimeSpan.FromSeconds(5));
var scriptActor = (ScriptActor)GetActorInstance(child);
Assert.NotNull(scriptActor.SeedAttributesReference);
Assert.False(
ReferenceEquals(scriptActor.SeedAttributesReference, liveAttributes),
$"Script Actor '{name}' was seeded from the Instance Actor's live " +
"_attributes dictionary by reference (SiteRuntime-017). It must be " +
"given a private snapshot copy.");
// The snapshot must still carry the same point-in-time contents.
Assert.Equal(liveAttributes.Count, scriptActor.SeedAttributesReference!.Count);
foreach (var kvp in liveAttributes)
{
Assert.True(scriptActor.SeedAttributesReference.ContainsKey(kvp.Key));
}
}
// The Alarm Actor must likewise be seeded from an isolated copy.
var alarmChild = await Sys.ActorSelection(testRef.Path / "alarm-HighTemp")
.ResolveOne(TimeSpan.FromSeconds(5));
var alarmActor = (AlarmActor)GetActorInstance(alarmChild);
Assert.NotNull(alarmActor.SeedAttributesReference);
Assert.False(
ReferenceEquals(alarmActor.SeedAttributesReference, liveAttributes),
"Alarm Actor 'HighTemp' was seeded from the Instance Actor's live " +
"_attributes dictionary by reference (SiteRuntime-017). It must be " +
"given a private snapshot copy.");
Assert.Equal(liveAttributes.Count, alarmActor.SeedAttributesReference!.Count);
}
}
@@ -0,0 +1,223 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using 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).
/// </summary>
public class InstanceActorIntegrationTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly string _dbFile;
public InstanceActorIntegrationTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-int-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = 30
};
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private IActorRef CreateInstanceWithScripts(
string instanceName,
IReadOnlyList<ResolvedScript>? scripts = null,
IReadOnlyList<ResolvedAlarm>? alarms = null)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" }
],
Scripts = scripts ?? [],
Alarms = alarms ?? []
};
return ActorOf(Props.Create(() => new InstanceActor(
instanceName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null, // no stream manager
_options,
NullLogger<InstanceActor>.Instance)));
}
[Fact]
public void InstanceActor_CreatesScriptActors_FromConfig()
{
var scripts = new[]
{
new ResolvedScript
{
CanonicalName = "GetValue",
Code = "42"
}
};
var actor = CreateInstanceWithScripts("Pump1", scripts);
// Verify script actor is reachable via CallScript
actor.Tell(new ScriptCallRequest("GetValue", null, 0, "corr-1"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.Equal(42, result.ReturnValue);
}
[Fact]
public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError()
{
var actor = CreateInstanceWithScripts("Pump1");
actor.Tell(new ScriptCallRequest("NonExistent", null, 0, "corr-2"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(5));
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage);
}
[Fact]
public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox()
{
// WP-24: Instance Actor processes messages sequentially.
// Verify that rapid attribute changes don't corrupt state.
var actor = CreateInstanceWithScripts("Pump1");
// Send many rapid set commands
for (int i = 0; i < 50; i++)
{
actor.Tell(new SetStaticAttributeCommand(
$"corr-{i}", "Pump1", "Temperature", $"{i}", DateTimeOffset.UtcNow));
}
// Each static write replies with a SetStaticAttributeResponse; drain all
// 50 — the FIFO mailbox guarantees they are processed in order.
for (int i = 0; i < 50; i++)
{
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
}
// The last value should be the final one
actor.Tell(new GetAttributeRequest(
"corr-final", "Pump1", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("49", response.Value?.ToString());
}
[Fact]
public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot()
{
var actor = CreateInstanceWithScripts("Pump1");
// Wait for initialization
Thread.Sleep(500);
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-1"));
var snapshot = ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
Assert.Equal("Pump1", snapshot.InstanceUniqueName);
Assert.True(snapshot.AttributeValues.Count >= 2); // Temperature + Status
Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue);
}
[Fact]
public void InstanceActor_WP25_DebugViewSubscribe_NoLongerForwardsEventsViaClusterClient()
{
// Events now flow via gRPC (SiteStreamManager → StreamRelayActor → gRPC),
// not via ClusterClient. Subscribing returns a snapshot but ongoing events
// are NOT forwarded to the subscriber actor.
var actor = CreateInstanceWithScripts("Pump1");
// Subscribe to debug view — should still get snapshot
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-2"));
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
// Change an attribute
actor.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "200", "Good", DateTimeOffset.UtcNow));
// Should NOT receive change notification (old ClusterClient path removed)
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
[Fact]
public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications()
{
var actor = CreateInstanceWithScripts("Pump1");
// Subscribe
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-3"));
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
// Unsubscribe
actor.Tell(new UnsubscribeDebugViewRequest("Pump1", "debug-3"));
// Change attribute
actor.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "300", "Good", DateTimeOffset.UtcNow));
// Should NOT receive change notification
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
[Fact]
public void InstanceActor_CreatesAlarmActors_FromConfig()
{
var alarms = new[]
{
new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}",
PriorityLevel = 1
}
};
var actor = CreateInstanceWithScripts("Pump1", alarms: alarms);
// Wait for initialization
Thread.Sleep(500);
// Verify alarm actor was created by checking the debug snapshot includes the alarm
actor.Tell(new DebugSnapshotRequest("Pump1", "snap-alarm"));
var snapshot = ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
Assert.Single(snapshot.AlarmStates);
Assert.Equal("HighTemp", snapshot.AlarmStates[0].AlarmName);
}
}
@@ -0,0 +1,216 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using Akka.TestKit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for SiteRuntime-001: Instance.SetAttribute must route writes
/// to the Data Connection Layer for data-sourced attributes instead of persisting
/// a local static override.
/// </summary>
public class InstanceActorSetAttributeTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly string _dbFile;
public InstanceActorSetAttributeTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-setattr-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions();
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config, IActorRef? dclManager)
{
return ActorOf(Props.Create(() => new InstanceActor(
instanceName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
dclManager)));
}
/// <summary>
/// Drains the startup <see cref="SubscribeTagsRequest"/> the Instance Actor emits
/// to the DCL in PreStart, then returns the next <see cref="WriteTagRequest"/>.
/// </summary>
private static WriteTagRequest ExpectWriteTag(TestProbe dclProbe)
=> dclProbe.FishForMessage<WriteTagRequest>(_ => true, TimeSpan.FromSeconds(5));
private static FlattenedConfiguration DataSourcedConfig(string instanceName) => new()
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Setpoint",
Value = "10",
DataType = "Double",
DataSourceReference = "/Motor/Setpoint",
BoundDataConnectionName = "OpcServer1"
}
]
};
[Fact]
public async Task SetAttribute_DataSourcedAttribute_IssuesDclWriteAndDoesNotPersistOverride()
{
var config = DataSourcedConfig("PumpDcl1");
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpDcl1", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-dcl", "PumpDcl1", "Setpoint", "55", DateTimeOffset.UtcNow));
// The Instance Actor must forward a WriteTagRequest to the DCL manager.
var write = ExpectWriteTag(dclProbe);
Assert.Equal("OpcServer1", write.ConnectionName);
Assert.Equal("/Motor/Setpoint", write.TagPath);
Assert.Equal("55", write.Value);
// DCL confirms the write.
dclProbe.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow));
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
// No static override should be persisted for a data-sourced attribute.
await Task.Delay(300);
var overrides = await _storage.GetStaticOverridesAsync("PumpDcl1");
Assert.Empty(overrides);
}
[Fact]
public void SetAttribute_DataSourcedAttribute_DoesNotOptimisticallyUpdateMemory()
{
var config = DataSourcedConfig("PumpDcl2");
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpDcl2", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-dcl2", "PumpDcl2", "Setpoint", "999", DateTimeOffset.UtcNow));
var write = ExpectWriteTag(dclProbe);
dclProbe.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow));
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
// In-memory value must still be the original config value — it is only
// updated when the subscription delivers the confirmed device value.
actor.Tell(new GetAttributeRequest("corr-get", "PumpDcl2", "Setpoint", DateTimeOffset.UtcNow));
var get = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.Equal("10", get.Value?.ToString());
}
[Fact]
public void SetAttribute_DataSourcedAttribute_DclWriteFailure_ReturnedToCaller()
{
var config = DataSourcedConfig("PumpDcl3");
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpDcl3", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-dcl3", "PumpDcl3", "Setpoint", "42", DateTimeOffset.UtcNow));
var write = ExpectWriteTag(dclProbe);
dclProbe.Reply(new WriteTagResponse(write.CorrelationId, false, "device rejected write", DateTimeOffset.UtcNow));
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.False(response.Success);
Assert.Contains("device rejected write", response.ErrorMessage);
}
[Fact]
public async Task SetAttribute_StaticAttribute_StillPersistsOverrideAndDoesNotCallDcl()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpStatic1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" }
]
};
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpStatic1", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-static", "PumpStatic1", "Label", "Backup", DateTimeOffset.UtcNow));
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
// DCL must NOT receive a write for a static attribute.
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
await Task.Delay(300);
var overrides = await _storage.GetStaticOverridesAsync("PumpStatic1");
Assert.Single(overrides);
Assert.Equal("Backup", overrides["Label"]);
}
// SiteRuntime-025: 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.
[Fact]
public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpUnknown",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" }
]
};
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpUnknown", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-unknown", "PumpUnknown", "notARealAttr", "x", DateTimeOffset.UtcNow));
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.False(response.Success);
Assert.Contains("Unknown attribute", response.ErrorMessage);
Assert.Contains("notARealAttr", response.ErrorMessage);
// The DCL must NOT receive any write — the attribute does not exist.
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// No durable override row should be persisted for an unknown attribute —
// otherwise the polluting key resurrects on every restart via
// HandleOverridesLoaded.
await Task.Delay(300);
var overrides = await _storage.GetStaticOverridesAsync("PumpUnknown");
Assert.DoesNotContain("notARealAttr", overrides.Keys);
}
}
@@ -0,0 +1,370 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Tests for InstanceActor: attribute loading, static overrides, and persistence.
/// </summary>
public class InstanceActorTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly string _dbFile;
public InstanceActorTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-actor-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions();
}
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config)
{
return ActorOf(Props.Create(() => new InstanceActor(
instanceName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null, // no stream manager in tests
_options,
NullLogger<InstanceActor>.Instance)));
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
[Fact]
public void InstanceActor_LoadsAttributesFromConfig()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" }
]
};
var actor = CreateInstanceActor("Pump1", config);
// Query for an attribute that exists
actor.Tell(new GetAttributeRequest(
"corr-1", "Pump1", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>();
Assert.True(response.Found);
Assert.Equal("98.6", response.Value?.ToString());
Assert.Equal("corr-1", response.CorrelationId);
}
[Fact]
public void InstanceActor_GetAttribute_NotFound_ReturnsFalse()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes = []
};
var actor = CreateInstanceActor("Pump1", config);
actor.Tell(new GetAttributeRequest(
"corr-2", "Pump1", "NonExistent", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>();
Assert.False(response.Found);
Assert.Null(response.Value);
}
[Fact]
public void InstanceActor_SetStaticAttribute_UpdatesInMemory()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
]
};
var actor = CreateInstanceActor("Pump1", config);
// A static attribute write replies with SetStaticAttributeResponse.
actor.Tell(new SetStaticAttributeCommand(
"corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow));
var setResponse = ExpectMsg<SetStaticAttributeResponse>();
Assert.True(setResponse.Success);
// Verify the value changed in memory
actor.Tell(new GetAttributeRequest(
"corr-4", "Pump1", "Temperature", DateTimeOffset.UtcNow));
var getResponse = ExpectMsg<GetAttributeResponse>();
Assert.True(getResponse.Found);
Assert.Equal("100.0", getResponse.Value?.ToString());
}
[Fact]
public async Task InstanceActor_SetStaticAttribute_PersistsToSQLite()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpPersist1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
]
};
var actor = CreateInstanceActor("PumpPersist1", config);
actor.Tell(new SetStaticAttributeCommand(
"corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow));
// A static attribute write replies with SetStaticAttributeResponse once the
// in-memory state is updated; then wait for the async SQLite persist.
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(500);
// Verify it persisted to SQLite
var overrides = await _storage.GetStaticOverridesAsync("PumpPersist1");
Assert.Single(overrides);
Assert.Equal("100.0", overrides["Temperature"]);
}
[Fact]
public async Task InstanceActor_LoadsStaticOverridesFromSQLite()
{
// Pre-populate overrides in SQLite
await _storage.SetStaticOverrideAsync("PumpOverride1", "Temperature", "200.0");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpOverride1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
]
};
var actor = CreateInstanceActor("PumpOverride1", config);
// Wait for the async override loading to complete (PipeTo)
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest(
"corr-5", "PumpOverride1", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>();
Assert.True(response.Found);
// The override value should take precedence over the config default
Assert.Equal("200.0", response.Value?.ToString());
}
[Fact]
public async Task StaticOverride_ResetOnRedeployment()
{
// Set up an override
await _storage.SetStaticOverrideAsync("PumpRedeploy", "Temperature", "200.0");
// Verify override exists
var overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy");
Assert.Single(overrides);
// Clear overrides (simulates what DeploymentManager does on redeployment)
await _storage.ClearStaticOverridesAsync("PumpRedeploy");
overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy");
Assert.Empty(overrides);
// Create actor with fresh config -- should NOT have the override
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpRedeploy",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }
]
};
var actor = CreateInstanceActor("PumpRedeploy", config);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest(
"corr-6", "PumpRedeploy", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>();
Assert.Equal("98.6", response.Value?.ToString());
}
[Fact]
public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Temperature",
Value = "0",
DataType = "Double",
DataSourceReference = "/Motor/Temperature",
BoundDataConnectionName = "OpcServer1"
}
]
};
var actor = CreateInstanceActor("Pump1", config);
actor.Tell(new GetAttributeRequest(
"corr-quality-1", "Pump1", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>();
Assert.True(response.Found);
Assert.Equal("Uncertain", response.Quality);
}
/// <summary>
/// SiteRuntime-019: 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"/>
/// — the dead handlers that replied with a misleading "success"
/// acknowledgement were removed. Sending one to the Instance Actor now goes
/// unhandled and produces no <see cref="InstanceLifecycleResponse"/>.
/// </summary>
[Fact]
public void InstanceActor_DoesNotHandleDisableOrEnableCommands()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes = []
};
var actor = CreateInstanceActor("Pump1", config);
actor.Tell(new DisableInstanceCommand("cmd-disable", "Pump1", DateTimeOffset.UtcNow));
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(new EnableInstanceCommand("cmd-enable", "Pump1", DateTimeOffset.UtcNow));
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void InstanceActor_StaticAttribute_StartsWithGoodQuality()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Label",
Value = "Main Pump",
DataType = "String"
// No DataSourceReference — static attribute
}
]
};
var actor = CreateInstanceActor("Pump1", config);
actor.Tell(new GetAttributeRequest(
"corr-quality-2", "Pump1", "Label", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>();
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
}
/// <summary>
/// A single PLC tag can back more than one attribute — e.g. two composed
/// cooling-tank modules whose members both reference the one simulated
/// <c>ns=3;s=Tank.Level</c> node. A <see cref="TagValueUpdate"/> must fan out
/// to every attribute that references that tag path, not just the last one
/// registered: the tag-path → attribute map previously overwrote on a shared
/// tag, leaving all but one attribute permanently Uncertain.
/// </summary>
[Fact]
public void InstanceActor_TagUpdate_FansOutToEveryAttributeSharingTheTagPath()
{
const string sharedTag = "ns=3;s=Tank.Level";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Motor-1",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "CoolingTank.Level", Value = "0", DataType = "Int",
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
},
new ResolvedAttribute
{
CanonicalName = "CoolingTank2.Level", Value = "0", DataType = "Int",
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Motor-1",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
dcl.Ref)));
// On startup the actor subscribes its data-sourced tags through the DCL.
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
// One value arrives for the tag that both attributes reference.
actor.Tell(new TagValueUpdate("PLC", sharedTag, 47, QualityCode.Good, DateTimeOffset.UtcNow));
// BOTH attributes must reflect it — not just the last-registered one.
foreach (var attrName in new[] { "CoolingTank.Level", "CoolingTank2.Level" })
{
actor.Tell(new GetAttributeRequest("corr-fanout", "Motor-1", attrName, DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("47", response.Value?.ToString());
Assert.Equal("Good", response.Quality);
}
}
}
@@ -0,0 +1,438 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
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.
/// </summary>
public class ScriptActorTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ScriptCompilationService _compilationService;
public ScriptActorTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
_options = new SiteRuntimeOptions
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = 30
};
}
void IDisposable.Dispose()
{
Shutdown();
}
private Script<object?> CompileScript(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
[Fact]
public void ScriptActor_CallScript_ReturnsResult()
{
var compiled = CompileScript("42");
var scriptConfig = new ResolvedScript
{
CanonicalName = "GetAnswer",
Code = "42"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"GetAnswer",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance)));
// Ask pattern (WP-22) for CallScript
scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success, $"Script call failed: {result.ErrorMessage}");
Assert.Equal(42, result.ReturnValue);
}
[Fact]
public void ScriptActor_CallScript_WithParameters_Works()
{
var compiled = CompileScript("(int)Parameters[\"x\"] + (int)Parameters[\"y\"]");
var scriptConfig = new ResolvedScript
{
CanonicalName = "Add",
Code = "(int)Parameters[\"x\"] + (int)Parameters[\"y\"]"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Add",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance)));
var parameters = new Dictionary<string, object?> { ["x"] = 3, ["y"] = 4 };
scriptActor.Tell(new ScriptCallRequest("Add", parameters, 0, "corr-2"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.Equal(7, result.ReturnValue);
}
[Fact]
public void ScriptActor_NullCompiledScript_ReturnsError()
{
var scriptConfig = new ResolvedScript
{
CanonicalName = "Broken",
Code = ""
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Broken",
"TestInstance",
instanceActor.Ref,
null, // no compiled script
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance)));
scriptActor.Tell(new ScriptCallRequest("Broken", null, 0, "corr-3"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(5));
Assert.False(result.Success);
Assert.Contains("not compiled", result.ErrorMessage);
}
[Fact]
public void ScriptActor_ValueChangeTrigger_SpawnsExecution()
{
var compiled = CompileScript("\"triggered\"");
var scriptConfig = new ResolvedScript
{
CanonicalName = "OnChange",
Code = "\"triggered\"",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"attributeName\":\"Temperature\"}"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"OnChange",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance)));
// Send an attribute change that matches the trigger
scriptActor.Tell(new AttributeValueChanged(
"TestInstance", "Temperature", "Temperature", "100.0", "Good", DateTimeOffset.UtcNow));
// The script should execute (we can't easily verify the output since it's fire-and-forget)
// But we can verify the actor doesn't crash
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
[Fact]
public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon()
{
var compiled = CompileScript("\"ok\"");
var scriptConfig = new ResolvedScript
{
CanonicalName = "Throttled",
Code = "\"ok\"",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"attributeName\":\"Temp\"}",
MinTimeBetweenRuns = TimeSpan.FromMinutes(10) // long minimum
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Throttled",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance)));
// First trigger -- should execute
scriptActor.Tell(new AttributeValueChanged(
"TestInstance", "Temp", "Temp", "1", "Good", DateTimeOffset.UtcNow));
// Second trigger immediately -- should be skipped due to min time
scriptActor.Tell(new AttributeValueChanged(
"TestInstance", "Temp", "Temp", "2", "Good", DateTimeOffset.UtcNow));
// No crash expected
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
[Fact]
public void ScriptActor_WP32_ScriptFailure_DoesNotDisable()
{
// Script that throws an exception
var compiled = CompileScript("throw new System.Exception(\"boom\")");
var scriptConfig = new ResolvedScript
{
CanonicalName = "Failing",
Code = "throw new System.Exception(\"boom\")"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Failing",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance)));
// First call -- fails
scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-1"));
var result1 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result1.Success);
// Second call -- should still work (script not disabled after failure)
scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-2"));
var result2 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result2.Success); // Still fails, but the actor is still alive
}
// ── WhileTrue trigger mode (Conditional + Expression) ──────────────────
//
// A fired script runs `Instance.SetAttribute("Fired", "1")`, which the
// Instance Actor receives as a SetStaticAttributeCommand. The probe stands
// in for the Instance Actor: an auto-pilot replies so each execution
// completes promptly (freeing the script-execution scheduler), while every
// command remains observable via ExpectMsg — one command per script firing.
private const string FiringScriptCode = "await Instance.SetAttribute(\"Fired\", \"1\")";
/// <summary>Builds a ScriptActor whose script fires one observable command per run.</summary>
private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
string name,
string triggerType,
string triggerConfig,
TimeSpan? minTimeBetweenRuns,
Script<object?>? triggerExpression = null)
{
var compiled = CompileScript(FiringScriptCode);
var scriptConfig = new ResolvedScript
{
CanonicalName = name,
Code = FiringScriptCode,
TriggerType = triggerType,
TriggerConfiguration = triggerConfig,
MinTimeBetweenRuns = minTimeBetweenRuns
};
var instance = CreateTestProbe();
instance.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
{
if (message is SetStaticAttributeCommand cmd)
{
sender.Tell(new SetStaticAttributeResponse(
cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName,
true, null, DateTimeOffset.UtcNow));
}
return AutoPilot.KeepRunning;
}));
var actor = ActorOf(Props.Create(() => new ScriptActor(
name,
"TestInstance",
instance.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance,
triggerExpression,
null,
null,
null)));
return (actor, instance);
}
private AttributeValueChanged Change(string attribute, object? value) =>
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
private Script<object?> CompileTriggerExpression(string expression) =>
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
[Fact]
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
{
// WhileTrue re-fire cadence is the script's MinTimeBetweenRuns.
var (actor, instance) = CreateTriggeredActor(
"CondWhile",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
// Temp 100 > 50 -> false->true edge: fire immediately.
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
// Then the timer re-fires while the condition still holds.
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
{
var (actor, instance) = CreateTriggeredActor(
"CondStop",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // at least one tick
// Temp 10 -> condition false: the re-fire timer stops.
actor.Tell(Change("Temp", "10"));
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
{
var (actor, instance) = CreateTriggeredActor(
"CondReArm",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
actor.Tell(Change("Temp", "100")); // true edge -> fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
actor.Tell(Change("Temp", "10")); // false -> stop
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(Change("Temp", "100")); // false->true again: re-arm + fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
{
// No MinTimeBetweenRuns -> no re-fire interval: degrades to a single edge fire.
var (actor, instance) = CreateTriggeredActor(
"CondNoInterval",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
minTimeBetweenRuns: null);
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
}
[Fact]
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
{
// Regression: OnTrue (the existing behavior) fires per matching change
// and never re-fires on a timer of its own.
var (actor, instance) = CreateTriggeredActor(
"CondOnTrue",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null);
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
actor.Tell(Change("Temp", "101"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
}
[Fact]
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
{
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
var (actor, instance) = CreateTriggeredActor(
"ExprWhile",
"Expression",
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300),
triggerExpr);
actor.Tell(Change("Active", "yes"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
actor.Tell(Change("Active", "no"));
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
{
// Regression: OnTrue expression triggers stay edge-triggered.
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
var (actor, instance) = CreateTriggeredActor(
"ExprOnTrue",
"Expression",
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null,
triggerExpr);
actor.Tell(Change("Active", "yes"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
actor.Tell(Change("Active", "yes")); // still true, no edge
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(Change("Active", "no")); // -> false
actor.Tell(Change("Active", "yes")); // false->true edge again
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Integration;
/// <summary>
/// Integration tests for multi-node failover scenarios.
/// These require two Akka.NET cluster nodes running simultaneously,
/// which is complex for unit tests. Marked with Category=Integration
/// for separate test run configuration.
///
/// WP-7: 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
/// - Second node joins as standby
/// - On primary graceful shutdown, singleton hands over to standby
/// - On primary crash, SBR detects failure and new singleton starts on standby
/// </summary>
public class FailoverIntegrationTests
{
[Fact]
[Trait("Category", "Integration")]
public void SingleNode_FormsSingletonCluster_RebuildFromSQLite()
{
// This is validated by the DeploymentManagerActorTests.
// A single-node cluster with min-nr-of-members=1 forms immediately.
// The DeploymentManager singleton starts and loads from SQLite.
// See: DeploymentManager_CreatesInstanceActors_FromStoredConfigs
Assert.True(true, "Covered by DeploymentManagerActorTests");
}
[Fact]
[Trait("Category", "Integration")]
public void GracefulShutdown_SingletonHandover()
{
// WP-6: CoordinatedShutdown triggers graceful cluster leave.
// The AkkaHostedService.StopAsync runs CoordinatedShutdown which:
// 1. Leaves the cluster gracefully
// 2. Singleton manager detects leave and starts handover
// 3. New singleton instance starts on the remaining node
//
// Actual multi-process test would require starting two Host processes.
// This is documented as a manual verification point.
Assert.True(true, "Requires multi-process test infrastructure");
}
[Fact]
[Trait("Category", "Integration")]
public void CrashRecovery_SBRDownsNode_SingletonRestartsOnStandby()
{
// When a node crashes (ungraceful):
// 1. Failure detector detects missing heartbeats (10s threshold)
// 2. SBR keep-oldest with down-if-alone=on resolves split brain
// 3. Crashed node is downed after stable-after period (15s)
// 4. ClusterSingletonManager starts new singleton on surviving node
// 5. New singleton loads all configs from SQLite and creates Instance Actors
//
// Total failover time: ~25s (10s detection + 15s stable-after)
Assert.True(true, "Requires multi-process test infrastructure");
}
[Fact]
[Trait("Category", "Integration")]
public void DualNodeRecovery_BothNodesRestart_FromSQLite()
{
// WP-7: 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
// 4. Instance Actors are recreated in staggered batches
// 5. Second node starts, joins existing cluster
// 6. Second node becomes standby for singleton
Assert.True(true, "Requires multi-process test infrastructure");
}
}
@@ -0,0 +1,104 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests;
/// <summary>
/// Negative tests verifying design constraints.
/// </summary>
public class NegativeTests
{
[Fact]
public async Task Schema_NoAlarmStateTable()
{
// Per design decision: no alarm state table in site SQLite schema.
// The site SQLite stores only deployed configs and static attribute overrides.
var storage = new SiteStorageService(
"Data Source=:memory:",
NullLogger<SiteStorageService>.Instance);
await storage.InitializeAsync();
// Try querying a non-existent alarm_states table — should throw
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
// Re-initialize on this connection to get the schema
await using var initCmd = connection.CreateCommand();
initCmd.CommandText = @"
CREATE TABLE IF NOT EXISTS deployed_configurations (
instance_unique_name TEXT PRIMARY KEY,
config_json TEXT NOT NULL,
deployment_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
deployed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS static_attribute_overrides (
instance_unique_name TEXT NOT NULL,
attribute_name TEXT NOT NULL,
override_value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, attribute_name)
);";
await initCmd.ExecuteNonQueryAsync();
// Verify alarm_states does NOT exist
await using var checkCmd = connection.CreateCommand();
checkCmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='alarm_states'";
var result = await checkCmd.ExecuteScalarAsync();
Assert.Null(result);
}
[Fact]
public async Task Schema_NoLocalConfigAuthoring()
{
// Per design: sites cannot author/modify template configurations locally.
// The SQLite schema has no template tables or editing tables.
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var initCmd = connection.CreateCommand();
initCmd.CommandText = @"
CREATE TABLE IF NOT EXISTS deployed_configurations (
instance_unique_name TEXT PRIMARY KEY,
config_json TEXT NOT NULL,
deployment_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
deployed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS static_attribute_overrides (
instance_unique_name TEXT NOT NULL,
attribute_name TEXT NOT NULL,
override_value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, attribute_name)
);";
await initCmd.ExecuteNonQueryAsync();
// Verify no template editing tables exist
await using var checkCmd = connection.CreateCommand();
checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table'";
var tableCount = (long)(await checkCmd.ExecuteScalarAsync())!;
// Only 2 tables in this manually-created schema (tests the constraint that
// no template editing tables exist in the manually-created subset)
Assert.Equal(2, tableCount);
}
[Fact]
public void SiteNode_DoesNotBindHttpPorts()
{
// Per design: site nodes use Host.CreateDefaultBuilder (not WebApplication.CreateBuilder).
// This is verified structurally — the site path in Program.cs does not configure Kestrel.
// This test documents the constraint; the actual verification is in the Program.cs code.
// The SiteRuntime project does not reference ASP.NET Core packages
var siteRuntimeAssembly = typeof(SiteRuntimeOptions).Assembly;
var referencedAssemblies = siteRuntimeAssembly.GetReferencedAssemblies();
Assert.DoesNotContain(referencedAssemblies,
a => a.Name != null && a.Name.Contains("AspNetCore"));
}
}
@@ -0,0 +1,156 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// WP-33: Local Artifact Storage tests — shared scripts, external systems,
/// database connections, notification lists.
/// </summary>
public class ArtifactStorageTests : IAsyncLifetime, IDisposable
{
private readonly string _dbFile;
private SiteStorageService _storage = null!;
public ArtifactStorageTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"artifact-test-{Guid.NewGuid():N}.db");
}
public async Task InitializeAsync()
{
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
await _storage.InitializeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
{
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
// ── Shared Script Storage ──
[Fact]
public async Task StoreSharedScript_RoundTrips()
{
await _storage.StoreSharedScriptAsync("CalcAvg", "return 42;", "{}", "int");
var scripts = await _storage.GetAllSharedScriptsAsync();
Assert.Single(scripts);
Assert.Equal("CalcAvg", scripts[0].Name);
Assert.Equal("return 42;", scripts[0].Code);
Assert.Equal("{}", scripts[0].ParameterDefinitions);
Assert.Equal("int", scripts[0].ReturnDefinition);
}
[Fact]
public async Task StoreSharedScript_Upserts_OnConflict()
{
await _storage.StoreSharedScriptAsync("CalcAvg", "return 1;", null, null);
await _storage.StoreSharedScriptAsync("CalcAvg", "return 2;", "{\"x\":\"int\"}", "int");
var scripts = await _storage.GetAllSharedScriptsAsync();
Assert.Single(scripts);
Assert.Equal("return 2;", scripts[0].Code);
Assert.Equal("{\"x\":\"int\"}", scripts[0].ParameterDefinitions);
}
[Fact]
public async Task StoreSharedScript_MultipleScripts()
{
await _storage.StoreSharedScriptAsync("Script1", "1", null, null);
await _storage.StoreSharedScriptAsync("Script2", "2", null, null);
await _storage.StoreSharedScriptAsync("Script3", "3", null, null);
var scripts = await _storage.GetAllSharedScriptsAsync();
Assert.Equal(3, scripts.Count);
}
[Fact]
public async Task StoreSharedScript_NullableFields()
{
await _storage.StoreSharedScriptAsync("Simple", "42", null, null);
var scripts = await _storage.GetAllSharedScriptsAsync();
Assert.Single(scripts);
Assert.Null(scripts[0].ParameterDefinitions);
Assert.Null(scripts[0].ReturnDefinition);
}
// ── External System Storage ──
[Fact]
public async Task StoreExternalSystem_DoesNotThrow()
{
await _storage.StoreExternalSystemAsync(
"WeatherAPI", "https://api.weather.com",
"ApiKey", "{\"key\":\"abc\"}", "{\"getForecast\":{}}");
// No exception = success. Query verification would need a Get method.
}
[Fact]
public async Task StoreExternalSystem_Upserts()
{
await _storage.StoreExternalSystemAsync("API1", "https://v1", "Basic", null, null);
await _storage.StoreExternalSystemAsync("API1", "https://v2", "ApiKey", "{}", null);
// Upsert should not throw
}
// ── Database Connection Storage ──
[Fact]
public async Task StoreDatabaseConnection_DoesNotThrow()
{
await _storage.StoreDatabaseConnectionAsync(
"MainDB", "Server=localhost;Database=main", 3, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task StoreDatabaseConnection_Upserts()
{
await _storage.StoreDatabaseConnectionAsync(
"DB1", "Server=old", 3, TimeSpan.FromSeconds(1));
await _storage.StoreDatabaseConnectionAsync(
"DB1", "Server=new", 5, TimeSpan.FromSeconds(2));
// Upsert should not throw
}
// ── Notification List Storage ──
[Fact]
public async Task StoreNotificationList_DoesNotThrow()
{
await _storage.StoreNotificationListAsync(
"Ops Team", ["ops@example.com", "admin@example.com"]);
}
[Fact]
public async Task StoreNotificationList_Upserts()
{
await _storage.StoreNotificationListAsync("Team1", ["a@b.com"]);
await _storage.StoreNotificationListAsync("Team1", ["x@y.com", "z@w.com"]);
// Upsert should not throw
}
// ── Schema includes all WP-33 tables ──
[Fact]
public async Task Initialize_CreatesAllArtifactTables()
{
// The initialize already ran. Verify by storing to each table.
await _storage.StoreSharedScriptAsync("s", "code", null, null);
await _storage.StoreExternalSystemAsync("e", "url", "None", null, null);
await _storage.StoreDatabaseConnectionAsync("d", "connstr", 1, TimeSpan.Zero);
await _storage.StoreNotificationListAsync("n", ["email@test.com"]);
// All succeeded without exceptions = tables exist
}
}
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// Tests for SiteStorageService using file-based SQLite (temp files).
/// Validates the schema, CRUD operations, and constraint behavior.
/// </summary>
public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
{
private readonly string _dbFile;
private SiteStorageService _storage = null!;
public SiteStorageServiceTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"site-storage-test-{Guid.NewGuid():N}.db");
}
public async Task InitializeAsync()
{
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
await _storage.InitializeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
{
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
[Fact]
public async Task InitializeAsync_CreatesTablesWithoutError()
{
// Already called in InitializeAsync — just verify no exception
// Call again to verify idempotency (CREATE IF NOT EXISTS)
await _storage.InitializeAsync();
}
[Fact]
public async Task StoreAndRetrieve_DeployedConfig_RoundTrips()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"test\":true}", "dep-001", "sha256:abc", isEnabled: true);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
Assert.Equal("Pump1", configs[0].InstanceUniqueName);
Assert.Equal("{\"test\":true}", configs[0].ConfigJson);
Assert.Equal("dep-001", configs[0].DeploymentId);
Assert.Equal("sha256:abc", configs[0].RevisionHash);
Assert.True(configs[0].IsEnabled);
}
[Fact]
public async Task StoreDeployedConfig_Upserts_OnConflict()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"v\":1}", "dep-001", "sha256:aaa", isEnabled: true);
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"v\":2}", "dep-002", "sha256:bbb", isEnabled: false);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
Assert.Equal("{\"v\":2}", configs[0].ConfigJson);
Assert.Equal("dep-002", configs[0].DeploymentId);
Assert.False(configs[0].IsEnabled);
}
[Fact]
public async Task RemoveDeployedConfig_RemovesConfigAndOverrides()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{}", "dep-001", "sha256:aaa", isEnabled: true);
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "100");
await _storage.RemoveDeployedConfigAsync("Pump1");
var configs = await _storage.GetAllDeployedConfigsAsync();
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Empty(configs);
Assert.Empty(overrides);
}
[Fact]
public async Task SetInstanceEnabled_UpdatesFlag()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{}", "dep-001", "sha256:aaa", isEnabled: true);
await _storage.SetInstanceEnabledAsync("Pump1", false);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.False(configs[0].IsEnabled);
await _storage.SetInstanceEnabledAsync("Pump1", true);
configs = await _storage.GetAllDeployedConfigsAsync();
Assert.True(configs[0].IsEnabled);
}
[Fact]
public async Task SetInstanceEnabled_NonExistent_DoesNotThrow()
{
// Should not throw for a missing instance
await _storage.SetInstanceEnabledAsync("DoesNotExist", true);
}
// ── Static Override Tests ──
[Fact]
public async Task SetAndGetStaticOverride_RoundTrips()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Single(overrides);
Assert.Equal("98.6", overrides["Temperature"]);
}
[Fact]
public async Task SetStaticOverride_Upserts_OnConflict()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "100.0");
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Single(overrides);
Assert.Equal("100.0", overrides["Temperature"]);
}
[Fact]
public async Task ClearStaticOverrides_RemovesAll()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
await _storage.SetStaticOverrideAsync("Pump1", "Pressure", "50.0");
await _storage.ClearStaticOverridesAsync("Pump1");
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Empty(overrides);
}
[Fact]
public async Task GetStaticOverrides_IsolatedPerInstance()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
await _storage.SetStaticOverrideAsync("Pump2", "Pressure", "50.0");
var pump1 = await _storage.GetStaticOverridesAsync("Pump1");
var pump2 = await _storage.GetStaticOverridesAsync("Pump2");
Assert.Single(pump1);
Assert.Single(pump2);
Assert.True(pump1.ContainsKey("Temperature"));
Assert.True(pump2.ContainsKey("Pressure"));
}
[Fact]
public async Task MultipleInstances_IndependentLifecycle()
{
await _storage.StoreDeployedConfigAsync("Pump1", "{}", "d1", "h1", true);
await _storage.StoreDeployedConfigAsync("Pump2", "{}", "d2", "h2", true);
await _storage.StoreDeployedConfigAsync("Pump3", "{}", "d3", "h3", false);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Equal(3, configs.Count);
await _storage.RemoveDeployedConfigAsync("Pump2");
configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Equal(2, configs.Count);
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "Pump2");
}
// ── Negative Tests ──
[Fact]
public async Task Schema_DoesNotContain_AlarmStateTable()
{
// Per design: no alarm state table in site SQLite
var configs = await _storage.GetAllDeployedConfigsAsync();
var overrides = await _storage.GetStaticOverridesAsync("nonexistent");
Assert.Empty(configs);
Assert.Empty(overrides);
}
}
@@ -0,0 +1,203 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Repositories;
/// <summary>
/// SiteRuntime-006 / SiteRuntime-007 regression tests for the site-local repositories.
///
/// SiteRuntime-006: 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
/// across process restarts (a freshly-constructed service/repository), so an ID handed
/// to a caller still resolves the same entity later.
/// </summary>
public class SiteRepositoryTests : IDisposable
{
private readonly string _dbFile;
public SiteRepositoryTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"site-repo-test-{Guid.NewGuid():N}.db");
}
public void Dispose()
{
try { File.Delete(_dbFile); } catch { /* cleanup */ }
GC.SuppressFinalize(this);
}
private SiteStorageService NewStorage()
=> new($"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
/// <summary>
/// SiteRuntime-006: 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>
[Fact]
public async Task ExternalSystemRepository_RoundTripsStoredDefinition()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync(
"WeatherApi", "https://api.example.com", "ApiKey", "{\"key\":\"x\"}", null);
var repo = new SiteExternalSystemRepository(storage);
var all = await repo.GetAllExternalSystemsAsync();
Assert.Single(all);
Assert.Equal("WeatherApi", all[0].Name);
Assert.Equal("https://api.example.com", all[0].EndpointUrl);
}
/// <summary>
/// SiteRuntime-007: 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>
[Fact]
public async Task ExternalSystemRepository_SyntheticId_IsStableAcrossRestart()
{
var storage1 = NewStorage();
await storage1.InitializeAsync();
await storage1.StoreExternalSystemAsync(
"StableSystem", "https://x", "None", null, null);
var repo1 = new SiteExternalSystemRepository(storage1);
var idBeforeRestart = (await repo1.GetAllExternalSystemsAsync())[0].Id;
// Simulate a process restart — brand-new service + repository instances.
var storage2 = NewStorage();
var repo2 = new SiteExternalSystemRepository(storage2);
var idAfterRestart = (await repo2.GetAllExternalSystemsAsync())[0].Id;
Assert.Equal(idBeforeRestart, idAfterRestart);
// And the by-ID lookup must succeed using the pre-restart ID.
var found = await repo2.GetExternalSystemByIdAsync(idBeforeRestart);
Assert.NotNull(found);
Assert.Equal("StableSystem", found.Name);
}
// ── ExternalSystemGateway-011: name-keyed repository lookups ──
/// <summary>
/// ExternalSystemGateway-011: the site repository's name-keyed external-system
/// lookup returns the matching row, and the same synthetic ID as the by-ID path.
/// </summary>
[Fact]
public async Task ExternalSystemRepository_GetByName_ReturnsMatchingDefinition()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync(
"Alpha", "https://alpha.test", "ApiKey", "{\"key\":\"x\"}", null);
await storage.StoreExternalSystemAsync(
"Beta", "https://beta.test", "Basic", null, null);
var repo = new SiteExternalSystemRepository(storage);
var found = await repo.GetExternalSystemByNameAsync("Beta");
Assert.NotNull(found);
Assert.Equal("Beta", found!.Name);
Assert.Equal("https://beta.test", found.EndpointUrl);
// The by-name path must produce the same synthetic ID as the by-id path.
var byId = await repo.GetExternalSystemByIdAsync(found.Id);
Assert.NotNull(byId);
Assert.Equal("Beta", byId!.Name);
}
/// <summary>
/// ExternalSystemGateway-011: a missing name resolves to <c>null</c>.
/// </summary>
[Fact]
public async Task ExternalSystemRepository_GetByName_MissingName_ReturnsNull()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync(
"Alpha", "https://alpha.test", "ApiKey", null, null);
var repo = new SiteExternalSystemRepository(storage);
Assert.Null(await repo.GetExternalSystemByNameAsync("DoesNotExist"));
}
/// <summary>
/// ExternalSystemGateway-011: the site repository's name-keyed method lookup
/// returns the method scoped to its parent system, or <c>null</c> for a miss.
/// </summary>
[Fact]
public async Task ExternalSystemRepository_GetMethodByName_ResolvesScopedToSystem()
{
var storage = NewStorage();
await storage.InitializeAsync();
var methodDefs = "[{\"Name\":\"getData\",\"HttpMethod\":\"GET\",\"Path\":\"/data\"}]";
await storage.StoreExternalSystemAsync(
"WeatherApi", "https://api.example.com", "ApiKey", null, methodDefs);
var repo = new SiteExternalSystemRepository(storage);
var system = await repo.GetExternalSystemByNameAsync("WeatherApi");
Assert.NotNull(system);
var method = await repo.GetMethodByNameAsync(system!.Id, "getData");
Assert.NotNull(method);
Assert.Equal("getData", method!.Name);
Assert.Equal("GET", method.HttpMethod);
Assert.Null(await repo.GetMethodByNameAsync(system.Id, "noSuchMethod"));
}
/// <summary>
/// ExternalSystemGateway-011: the site repository's name-keyed database-connection
/// lookup returns the matching row, or <c>null</c> for a miss.
/// </summary>
[Fact]
public async Task DatabaseConnectionRepository_GetByName_ReturnsMatchingDefinition()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreDatabaseConnectionAsync(
"Plant", "Server=plant;Database=p;", maxRetries: 3, retryDelay: TimeSpan.FromSeconds(2));
await storage.StoreDatabaseConnectionAsync(
"Historian", "Server=hist;Database=h;", maxRetries: 0, retryDelay: TimeSpan.FromSeconds(5));
var repo = new SiteExternalSystemRepository(storage);
var found = await repo.GetDatabaseConnectionByNameAsync("Historian");
Assert.NotNull(found);
Assert.Equal("Historian", found!.Name);
Assert.Equal("Server=hist;Database=h;", found.ConnectionString);
Assert.Equal(0, found.MaxRetries);
Assert.Null(await repo.GetDatabaseConnectionByNameAsync("DoesNotExist"));
}
/// <summary>
/// SiteRuntime-007: the same stability guarantee for notification lists.
/// </summary>
[Fact]
public async Task NotificationRepository_SyntheticId_IsStableAcrossRestart()
{
var storage1 = NewStorage();
await storage1.InitializeAsync();
await storage1.StoreNotificationListAsync(
"OnCall", new[] { "a@example.com", "b@example.com" });
var repo1 = new SiteNotificationRepository(storage1);
var idBeforeRestart = (await repo1.GetAllNotificationListsAsync())[0].Id;
var storage2 = NewStorage();
var repo2 = new SiteNotificationRepository(storage2);
var found = await repo2.GetNotificationListByIdAsync(idBeforeRestart);
Assert.NotNull(found);
Assert.Equal("OnCall", found.Name);
}
}
@@ -0,0 +1,395 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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
/// <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
/// the id into the database gateway so the store-and-forward retry loop can
/// emit per-attempt + terminal telemetry under the same id.
/// </summary>
public class DatabaseCachedWriteEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> Telemetry { get; } = new();
public Exception? ThrowOnForward { get; set; }
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
if (ThrowOnForward != null)
{
return Task.FromException(ThrowOnForward);
}
Telemetry.Add(telemetry);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:WriteAudit";
/// <summary>
/// Audit Log #23: 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();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
ICachedCallTelemetryForwarder? forwarder,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditChannel.DbOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("myDb", packet.Audit.Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
// Audit Log #23 (ParentExecutionId): null for a non-routed run.
Assert.Null(packet.Audit.ParentExecutionId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel);
Assert.Equal("myDb", packet.Operational.Target);
Assert.Equal(SiteId, packet.Operational.SourceSite);
Assert.Equal("Submitted", packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedWrite_ProvenancePopulated()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
Assert.Equal(SourceScript, packet.Audit.SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
[Fact]
public async Task CachedWrite_RoutedRun_StampsParentExecutionId_OnSubmitTelemetry()
{
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
// carries the spawning execution's id; the CachedSubmit telemetry row
// must stamp it in ParentExecutionId.
var parentExecutionId = Guid.NewGuid();
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
}
[Fact]
public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): 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
/// loop later stamps the right provenance onto its audit rows. This
/// asserts the exact id/script (not <c>It.IsAny</c>), so a regression that
/// dropped the threading would fail here.
/// </summary>
[Fact]
public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
// The known TestExecutionId and SourceScript must reach the gateway
// unchanged — these are what the S&F retry loop persists and replays.
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript),
It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): 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>
[Fact]
public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway()
{
var parentExecutionId = Guid.NewGuid();
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == parentExecutionId)),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
/// <c>null</c> ParentExecutionId into the gateway — the additive default.
/// </summary>
[Fact]
public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == null)),
Times.Once);
}
[Fact]
public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task CachedWrite_StampsSourceNode_OnSubmitTelemetryRow()
{
// Symmetric to ExternalSystemCachedCallEmissionTests's
// CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow — locks
// the DbOutbound emitter against a future refactor that drops
// _sourceNode from the Database.CachedWrite CachedSubmit row.
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = new ScriptRuntimeContext.DatabaseHelper(
gateway.Object,
InstanceName,
NullLogger.Instance,
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: null,
sourceNode: "node-a");
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal("node-a", packet.Operational.SourceNode);
}
[Fact]
public async Task CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull()
{
// Default CreateHelper does NOT pass sourceNode — the legacy / test
// host path. The operational row carries null SourceNode, leaving
// central's SiteCalls.SourceNode NULL.
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Null(packet.Operational.SourceNode);
}
}
@@ -0,0 +1,394 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle A (Tasks A1+A2): 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
/// (<c>op="read"</c>, <c>rowsReturned=N</c>). The audit emission is
/// best-effort — a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's call, and the original ADO.NET result (or original
/// exception) must surface to the caller unchanged.
/// </summary>
public class DatabaseSyncEmissionTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> mirroring the M2 Bundle F 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
{
public List<AuditEvent> Events { get; } = new();
public Exception? ThrowOnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
if (ThrowOnWrite != null)
{
return Task.FromException(ThrowOnWrite);
}
Events.Add(evt);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:Sync";
private const string ConnectionName = "machineData";
/// <summary>
/// Audit Log #23: a fixed per-execution id used by the default
/// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter)
=> CreateHelper(gateway, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter,
Guid executionId,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
executionId,
auditWriter: auditWriter,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: null,
parentExecutionId: parentExecutionId);
}
/// <summary>
/// Spin up a fresh in-memory SQLite database with a tiny single-table
/// schema we can write to and read from. The connection is returned in
/// the open state so the test only has to call <c>Connection()</c> via
/// the helper. SQLite in-memory databases live as long as the connection
/// holding them, so the keep-alive root must outlive any auditing
/// wrapper the test exercises.
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
// The shared-cache name is per-test (Guid) so concurrent tests don't
// collide. mode=memory keeps it RAM-only; cache=shared lets the
// keep-alive root and the gateway-returned connection see the same
// in-memory DB. The keepAlive connection must remain open for the
// duration of the test or the in-memory DB is discarded.
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);" +
"INSERT INTO t (id, name) VALUES (1, 'alpha');" +
"INSERT INTO t (id, name) VALUES (2, 'beta');";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
[Fact]
public async Task Execute_InsertSuccess_EmitsOneEvent_KindDbWrite_StatusDelivered_OpWrite_RowsAffected()
{
using var keepAlive = new SqliteConnection("Data Source=k;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (3, 'gamma')";
var rows = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, rows);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
Assert.Equal(AuditKind.DbWrite, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
Assert.NotNull(evt.Extra);
Assert.Contains("\"op\":\"write\"", evt.Extra);
Assert.Contains("\"rowsAffected\":1", evt.Extra);
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.StartsWith(ConnectionName, evt.Target);
}
[Fact]
public async Task ExecuteScalar_Success_EmitsKindDbWrite_OpWrite()
{
using var keepAlive = new SqliteConnection("Data Source=k2;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM t";
var scalar = await cmd.ExecuteScalarAsync();
Assert.NotNull(scalar);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
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
// (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
// built-in path, plus the rowsAffected counter the wrapper observed.
Assert.Contains("\"op\":\"write\"", evt.Extra);
Assert.Contains("rowsAffected", evt.Extra);
}
[Fact]
public async Task Execute_Throws_EmitsEvent_StatusFailed_ErrorMessageSet()
{
using var keepAlive = new SqliteConnection("Data Source=k3;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
// Reference an undefined column — SQLite throws SqliteException synchronously.
cmd.CommandText = "INSERT INTO t (does_not_exist) VALUES (1)";
await Assert.ThrowsAsync<SqliteException>(() => cmd.ExecuteNonQueryAsync());
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
Assert.NotNull(evt.ErrorDetail);
Assert.Contains("does_not_exist", evt.ErrorDetail);
}
[Fact]
public async Task ExecuteReader_Success_EmitsKindDbWrite_OpRead_RowsReturned()
{
using var keepAlive = new SqliteConnection("Data Source=k4;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name FROM t ORDER BY id";
await using var reader = await cmd.ExecuteReaderAsync();
var rows = 0;
while (await reader.ReadAsync())
{
rows++;
}
// Close the reader explicitly so the audit emission (deferred to
// reader-close per the wrapper contract) fires before assertion.
await reader.CloseAsync();
Assert.Equal(2, rows);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
Assert.Equal(AuditKind.DbWrite, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.NotNull(evt.Extra);
Assert.Contains("\"op\":\"read\"", evt.Extra);
Assert.Contains("\"rowsReturned\":2", evt.Extra);
}
[Fact]
public async Task AuditWriter_Throws_ScriptCall_ReturnsOriginalResult()
{
using var keepAlive = new SqliteConnection("Data Source=k5;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter
{
ThrowOnWrite = new InvalidOperationException("audit writer down")
};
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (4, 'delta')";
var rows = await cmd.ExecuteNonQueryAsync();
// Original ADO.NET result must surface unchanged despite the audit
// writer faulting — the wrapper swallows + logs the audit failure.
Assert.Equal(1, rows);
Assert.Empty(writer.Events);
}
[Fact]
public async Task Provenance_PopulatedFromContext()
{
using var keepAlive = new SqliteConnection("Data Source=k6;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (5, 'epsilon')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
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
// 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
// default CreateHelper supplies no parentExecutionId.
Assert.Null(evt.ParentExecutionId);
Assert.NotEqual(Guid.Empty, evt.EventId);
}
[Fact]
public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext()
{
// Audit Log #23 (ParentExecutionId, Task 5): 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");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var helper = CreateHelper(gateway.Object, writer, executionId, parentExecutionId);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (9, 'theta')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.Equal(parentExecutionId, evt.ParentExecutionId);
Assert.Equal(executionId, evt.ExecutionId);
}
[Fact]
public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull()
{
// A normal (tag/timer) run is not routed — no parent id supplied, so
// the emitted DbWrite row's ParentExecutionId stays null.
using var keepAlive = new SqliteConnection("Data Source=kn;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (10, 'iota')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId()
{
using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(gateway.Object, writer, executionId);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.Equal(executionId, evt.ExecutionId);
// Sync one-shot call: CorrelationId is null (no operation lifecycle).
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task DurationMs_NonZero()
{
using var keepAlive = new SqliteConnection("Data Source=k7;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(gateway.Object, writer);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (6, 'zeta')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.DurationMs);
Assert.True(evt.DurationMs >= 0, $"DurationMs={evt.DurationMs} should be >= 0");
Assert.True(evt.DurationMs <= 5000, $"DurationMs={evt.DurationMs} should be <= 5000");
}
}
@@ -0,0 +1,285 @@
using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — execution-correlation tests exercised through a full
/// <see cref="ScriptRuntimeContext"/>:
///
/// <list type="bullet">
/// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
/// ctor: when no execution id is supplied (tag-change / timer-triggered
/// executions) a fresh, non-empty id is minted and stamped on the emitted rows.
/// </description></item>
/// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single
/// <see cref="AuditEvent.ExecutionId"/>. The per-operation
/// <see cref="AuditEvent.CorrelationId"/> stays null for these sync one-shot
/// calls — a sync call has no operation lifecycle.
/// </description></item>
/// </list>
/// </summary>
public class ExecutionCorrelationContextTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> capturing every emitted event
/// (mirrors the <c>CapturingAuditWriter</c> stubs in
/// <see cref="ExternalSystemCallAuditEmissionTests"/> /
/// <see cref="DatabaseSyncEmissionTests"/>).
/// </summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
Events.Add(evt);
return Task.CompletedTask;
}
}
private const string InstanceName = "Plant.Pump42";
private const string ConnectionName = "machineData";
/// <summary>
/// Builds a full <see cref="ScriptRuntimeContext"/> wired with the external
/// system client, database gateway and audit writer the cross-helper test
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
/// integration helpers (ExternalSystem / Database) never touch them — and
/// <paramref name="executionId"/> defaults to null so the ctor's
/// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
/// </summary>
private static ScriptRuntimeContext CreateContext(
IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter,
Guid? executionId = null,
Guid? parentExecutionId = null)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName,
logger: NullLogger.Instance,
externalSystemClient: externalSystemClient,
databaseGateway: databaseGateway,
storeAndForward: null,
siteCommunicationActor: null,
siteId: "site-77",
sourceScript: "ScriptActor:OnTick",
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
executionId: executionId,
parentExecutionId: parentExecutionId);
}
/// <summary>
/// Spin up a fresh in-memory SQLite database with a tiny single-table
/// schema. The keep-alive root must outlive any auditing wrapper the test
/// exercises (mirrors <c>DatabaseSyncEmissionTests.NewInMemoryDb</c>).
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
[Fact]
public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId()
{
// No executionId argument — the ScriptRuntimeContext ctor's
// `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id
// branch every other audit test bypasses by passing an explicit id).
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var context = CreateContext(client.Object, databaseGateway: null, writer);
await context.ExternalSystem.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
// A sync one-shot call has no operation lifecycle — CorrelationId is null.
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId()
{
// The execution-wide contract: an ExternalSystem.Call AND a sync
// Database write performed through ONE ScriptRuntimeContext must both
// carry the same ExecutionId, so an audit reader can tie every
// trust-boundary action from one script run together.
using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out var _);
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(innerDb);
var writer = new CapturingAuditWriter();
var context = CreateContext(client.Object, gateway.Object, writer);
// 1) outbound API call through the context's ExternalSystem helper.
await context.ExternalSystem.Call("ERP", "GetOrder");
// 2) sync DB write through the SAME context's Database helper.
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
await cmd.ExecuteNonQueryAsync();
}
Assert.Equal(2, writer.Events.Count);
var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
Assert.NotNull(apiRow.ExecutionId);
Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value);
// The ApiCall row and the DbWrite row, emitted by two different helpers
// resolved off one context, carry the identical ExecutionId.
Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId);
// Both are sync one-shot calls — neither carries a CorrelationId.
Assert.Null(apiRow.CorrelationId);
Assert.Null(dbRow.CorrelationId);
}
[Fact]
public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId()
{
// Audit Log #23 (ParentExecutionId, Task 5): 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
// fresh ExecutionId, distinct from the parent (the routed script is a
// new execution, it does not inherit the parent's id).
var parentExecutionId = Guid.NewGuid();
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var context = CreateContext(
client.Object,
databaseGateway: null,
writer,
// executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs.
parentExecutionId: parentExecutionId);
await context.ExternalSystem.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
// The parent id is stamped on the emitted row untouched.
Assert.Equal(parentExecutionId, evt.ParentExecutionId);
// The routed script's own ExecutionId is freshly generated, non-empty,
// and NOT the parent id — they are separate correlation values.
Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value);
}
[Fact]
public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow()
{
// A normal (tag-change / timer) script run is not inbound-API-routed —
// no ParentExecutionId is supplied, so every emitted audit row carries
// a null ParentExecutionId while the run still gets its own fresh
// ExecutionId.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var context = CreateContext(client.Object, databaseGateway: null, writer);
await context.ExternalSystem.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
}
[Fact]
public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext()
{
// The execution-wide contract extends to ParentExecutionId: an
// ExternalSystem.Call and a sync Database write performed through ONE
// routed context both carry the identical ParentExecutionId.
var parentExecutionId = Guid.NewGuid();
using var keepAlive = new SqliteConnection("Data Source=ecc-parent;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out var _);
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(innerDb);
var writer = new CapturingAuditWriter();
var context = CreateContext(
client.Object, gateway.Object, writer, parentExecutionId: parentExecutionId);
await context.ExternalSystem.Call("ERP", "GetOrder");
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
await cmd.ExecuteNonQueryAsync();
}
Assert.Equal(2, writer.Events.Count);
var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
Assert.Equal(parentExecutionId, apiRow.ParentExecutionId);
Assert.Equal(parentExecutionId, dbRow.ParentExecutionId);
}
}
@@ -0,0 +1,638 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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
/// <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
/// store-and-forward layer so the retry-loop emissions (Tasks E4/E5) can join
/// them by id. The audit emission is best-effort: a thrown forwarder must
/// never abort the script's call, and the original
/// <see cref="ExternalCallResult"/> must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCachedCallEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> Telemetry { get; } = new();
public Exception? ThrowOnForward { get; set; }
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
if (ThrowOnForward != null)
{
return Task.FromException(ThrowOnForward);
}
Telemetry.Add(telemetry);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: 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();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
ICachedCallTelemetryForwarder? forwarder,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task CachedCall_EmitsSubmitTelemetry_OnEnqueue()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
Assert.Single(forwarder.Telemetry);
var packet = forwarder.Telemetry[0];
Assert.Equal(AuditChannel.ApiOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("ERP.GetOrder", packet.Audit.Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState);
// Operational mirror — same id, Submitted, RetryCount 0, not terminal.
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("ApiOutbound", packet.Operational.Channel);
Assert.Equal("ERP.GetOrder", packet.Operational.Target);
Assert.Equal(SiteId, packet.Operational.SourceSite);
Assert.Equal("Submitted", packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.LastError);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var args = new Dictionary<string, object?> { ["orderId"] = 42 };
await helper.CachedCall("ERP", "GetOrder", args);
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit);
var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached);
var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve);
// Every row carries the request args; the two post-call rows also carry
// the response body (Submit precedes the call, so it has no response).
Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary);
Assert.Null(submit.Audit.ResponseSummary);
Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary);
Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary);
Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary);
Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary);
}
[Fact]
public async Task CachedCall_ReturnsTrackedOperationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var id1 = await helper.CachedCall("ERP", "GetOrder");
var id2 = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, id1);
Assert.NotEqual(default, id2);
Assert.NotEqual(id1, id2);
// Both ids were threaded into the client invocations.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id1,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id2,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): 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
/// retry loop later stamps the right provenance onto its audit rows.
/// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression
/// that dropped the threading would fail here.
/// </summary>
[Fact]
public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
// The known TestExecutionId and SourceScript must reach the client
// unchanged — these are what the S&F retry loop persists and replays.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript),
It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): 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>
[Fact]
public async Task CachedCall_ThreadsParentExecutionId_IntoClient()
{
var parentExecutionId = Guid.NewGuid();
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
await helper.CachedCall("ERP", "GetOrder");
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == parentExecutionId)),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a
/// <c>null</c> ParentExecutionId into the client — the additive default.
/// </summary>
[Fact]
public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(),
It.Is<Guid?>(id => id == null)),
Times.Once);
}
[Fact]
public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(client.Object, forwarder);
// Must not throw — best-effort emission contract.
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
// The underlying call still ran exactly once.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
[Fact]
public async Task CachedCall_Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
Assert.Equal(SourceScript, packet.Audit.SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
[Fact]
public async Task CachedCall_NoForwarder_StillReturnsTrackedOperationId()
{
// Forwarder not wired (tests / minimal hosts) — must still return a
// fresh id and invoke the underlying call.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var helper = CreateHelper(client.Object, forwarder: null);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): when the underlying client call
/// completes immediately (no S&amp;F buffering, <c>WasBuffered=false</c>),
/// the S&amp;F retry loop never engages and the
/// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call
/// helper itself must therefore emit the terminal lifecycle rows —
/// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c>
/// forever and the audit log would be missing the <c>Attempted</c> /
/// <c>CachedResolve</c> pair the M3 contract requires.
///
/// Expected emissions on immediate success:
/// 1. CachedSubmit / Submitted (already covered)
/// 2. ApiCallCached / Attempted
/// 3. CachedResolve / Delivered (TerminalAtUtc set)
/// </summary>
[Fact]
public async Task CachedCall_ImmediateSuccess_EmitsAttemptedAndCachedResolve()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
// is bypassed entirely.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
// Three telemetry packets emitted: Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry[0];
Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, submit.Audit.Status);
Assert.Equal(TestExecutionId, submit.Audit.ExecutionId);
Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
Assert.Null(submit.Operational.TerminalAtUtc);
var attempted = forwarder.Telemetry[1];
Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel);
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
// Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the
// per-execution id from the runtime context.
Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId);
Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId);
Assert.Equal("ERP.GetOrder", attempted.Audit.Target);
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = forwarder.Telemetry[2];
Assert.Equal(AuditChannel.ApiOutbound, resolve.Audit.Channel);
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId);
Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId);
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
Assert.Equal("Delivered", resolve.Operational.Status);
// Terminal row carries TerminalAtUtc.
Assert.NotNull(resolve.Operational.TerminalAtUtc);
// Audit Log #23 (ParentExecutionId): null on every script-side cached
// row for a non-routed run.
Assert.Null(submit.Audit.ParentExecutionId);
Assert.Null(attempted.Audit.ParentExecutionId);
Assert.Null(resolve.Audit.ParentExecutionId);
}
[Fact]
public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows()
{
// Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run
// carries the spawning execution's id; every script-side cached row
// (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in
// ParentExecutionId.
var parentExecutionId = Guid.NewGuid();
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
Assert.All(forwarder.Telemetry, t =>
Assert.Equal(parentExecutionId, t.Audit.ParentExecutionId));
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): the immediate-failure terminal
/// path. When the client returns <c>Success=false</c> with
/// <c>WasBuffered=false</c> (a permanent failure or a transient failure
/// without an S&amp;F engine to buffer it), the cached-call helper must
/// still emit Attempted + CachedResolve with the failed status.
/// </summary>
[Fact]
public async Task CachedCall_ImmediateFailure_EmitsAttemptedAndCachedResolveFailed()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
var attempted = forwarder.Telemetry[1];
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
// The per-attempt row carries the error message.
Assert.NotNull(attempted.Audit.ErrorMessage);
var resolve = forwarder.Telemetry[2];
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
// Immediate permanent failure -> Failed audit status / operational Failed.
Assert.Equal(AuditStatus.Failed, resolve.Audit.Status);
Assert.Equal("Failed", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
Assert.NotNull(resolve.Operational.LastError);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): when the client reports
/// <c>WasBuffered=true</c>, the helper hands the operation to S&amp;F and
/// the retry-loop observer owns the Attempted + Resolve emissions. The
/// helper must NOT emit those rows itself (otherwise we'd get duplicate
/// Attempted + Resolve audit rows under the same tracking id).
/// </summary>
[Fact]
public async Task CachedCall_BufferedPath_DoesNotEmitTerminalTelemetryFromHelper()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// S&F took ownership — Attempted + Resolve come from the
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
// Only the CachedSubmit row — no Attempted / Resolve from the helper.
var only = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditKind.CachedSubmit, only.Audit.Kind);
}
// ── SourceNode-stamping (Task 14) ──
[Fact]
public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow()
{
// SourceNode-stamping (Task 14): 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.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
// Immediate completion — helper produces all three rows itself.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
client.Object,
InstanceName,
NullLogger.Instance,
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder,
parentExecutionId: null,
sourceNode: "node-a");
await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode));
}
[Fact]
public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull()
{
// Default CreateHelper does NOT pass sourceNode — the legacy / test
// host path. Every operational row carries null SourceNode, leaving
// central's SiteCalls.SourceNode NULL.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
var only = Assert.Single(forwarder.Telemetry);
Assert.Null(only.Operational.SourceNode);
}
}
@@ -0,0 +1,344 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M2 Bundle F (Task F1): 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
/// is best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's call, and the original <see cref="ExternalCallResult"/>
/// (or original exception) must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCallAuditEmissionTests
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> that records every event passed to
/// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
/// catastrophic audit-writer failure that the wrapper must swallow.
/// </summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Exception? ThrowOnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
if (ThrowOnWrite != null)
{
return Task.FromException(ThrowOnWrite);
}
Events.Add(evt);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed per-execution id used by the default
/// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
Guid executionId,
Guid? parentExecutionId = null)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
executionId,
auditWriter,
SiteId,
SourceScript,
cachedForwarder: null,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.True(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal("ERP.GetOrder", evt.Target);
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);
// No call arguments → null request summary; the response body is captured.
Assert.Null(evt.RequestSummary);
Assert.Equal("{}", evt.ResponseSummary);
}
[Fact]
public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var args = new Dictionary<string, object?> { ["city"] = "Dublin" };
await helper.Call("Weather", "GetCurrent", args);
var evt = Assert.Single(writer.Events);
// RequestSummary is the serialized argument dictionary; ResponseSummary
// is the verbatim response body. (Cap + redaction are the writer's job.)
Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary);
Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
}
[Fact]
public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(false, null, "Transient error: HTTP 500 from ERP: Internal Server Error"));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.False(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(500, evt.HttpStatus);
Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
Assert.Contains("500", evt.ErrorMessage);
}
[Fact]
public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(false, null, "Permanent error: HTTP 400 from ERP: Bad Request"));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.False(result.Success);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(400, evt.HttpStatus);
}
[Fact]
public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException()
{
var client = new Mock<IExternalSystemClient>();
var networkEx = new HttpRequestException("network down");
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(networkEx);
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var thrown = await Assert.ThrowsAsync<HttpRequestException>(() => helper.Call("ERP", "GetOrder"));
Assert.Same(networkEx, thrown);
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Null(evt.HttpStatus);
Assert.Equal("network down", evt.ErrorMessage);
Assert.NotNull(evt.ErrorDetail);
Assert.Contains("HttpRequestException", evt.ErrorDetail);
}
[Fact]
public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged()
{
var client = new Mock<IExternalSystemClient>();
var expected = new ExternalCallResult(true, "{\"v\":1}", null);
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var writer = new CapturingAuditWriter
{
ThrowOnWrite = new InvalidOperationException("audit writer down")
};
var helper = CreateHelper(client.Object, writer);
var result = await helper.Call("ERP", "GetOrder");
Assert.Same(expected, result);
Assert.Empty(writer.Events);
}
[Fact]
public async Task Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, null, null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
var beforeId = Guid.NewGuid();
await helper.Call("ERP", "GetOrder");
var evt = writer.Events[0];
Assert.NotEqual(beforeId, evt.EventId);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
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
// 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
// default CreateHelper supplies no parentExecutionId.
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task Call_RoutedRun_StampsParentExecutionId_FromContext()
{
// Audit Log #23 (ParentExecutionId, Task 5): 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>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var parentExecutionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, TestExecutionId, parentExecutionId);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Equal(parentExecutionId, evt.ParentExecutionId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
}
[Fact]
public async Task Call_NonRoutedRun_ParentExecutionIdIsNull()
{
// A normal (tag/timer) run is not routed — no parent id supplied, so
// the emitted ApiCall row's ParentExecutionId stays null.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Equal(executionId, evt.ExecutionId);
// Sync one-shot call: CorrelationId is null (no operation lifecycle).
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
await helper.Call("ERP", "GetCustomer");
Assert.Equal(2, writer.Events.Count);
// Both sync ApiCall rows from one execution carry the same ExecutionId.
Assert.Equal(executionId, writer.Events[0].ExecutionId);
Assert.Equal(executionId, writer.Events[1].ExecutionId);
Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
// Neither sync call carries a CorrelationId.
Assert.Null(writer.Events[0].CorrelationId);
Assert.Null(writer.Events[1].CorrelationId);
}
[Fact]
public async Task DurationMs_Recorded_NonZero()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "Slow", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.Returns(async () =>
{
await Task.Delay(20);
return new ExternalCallResult(true, null, null);
});
var writer = new CapturingAuditWriter();
var helper = CreateHelper(client.Object, writer);
await helper.Call("ERP", "Slow");
var evt = writer.Events[0];
Assert.NotNull(evt.DurationMs);
Assert.True(evt.DurationMs >= 0, $"DurationMs={evt.DurationMs} should be >= 0");
Assert.True(evt.DurationMs <= 5000, $"DurationMs={evt.DurationMs} should be <= 5000");
}
}
@@ -0,0 +1,320 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Notification Outbox (Task 19): 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
/// 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.
/// </summary>
public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _saf;
public NotifyHelperTests()
{
var dbName = $"NotifyTests_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
var options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10)
};
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
protected override void Dispose(bool disposing)
{
if (disposing)
{
_keepAlive.Dispose();
}
base.Dispose(disposing);
}
private ScriptRuntimeContext.NotifyHelper CreateHelper(
IActorRef siteCommunicationActor,
string? sourceScript = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
{
return new ScriptRuntimeContext.NotifyHelper(
_saf,
siteCommunicationActor,
"site-7",
"Plant.Pump3",
sourceScript,
TimeSpan.FromSeconds(3),
NullLogger.Instance,
executionId ?? Guid.NewGuid(),
auditWriter: null,
parentExecutionId: parentExecutionId,
sourceNode: sourceNode);
}
[Fact]
public async Task Send_EnqueuesNotificationIntoStoreAndForward_AndReturnsNotificationIdImmediately()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
// Send returns a non-empty NotificationId string immediately (no central round-trip).
Assert.False(string.IsNullOrEmpty(notificationId));
// Exactly one Notification-category message was buffered for the S&F forwarder.
var depth = await _saf.GetBufferDepthAsync();
Assert.Equal(1, depth.GetValueOrDefault(StoreAndForwardCategory.Notification));
}
[Fact]
public async Task Send_BufferedPayload_CarriesListSubjectBodyAndNotificationId()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
Assert.Equal(StoreAndForwardCategory.Notification, buffered!.Category);
Assert.Equal("Operators", buffered.Target);
Assert.Equal("Plant.Pump3", buffered.OriginInstanceName);
// The S&F message Id is the NotificationId — the single idempotency key.
Assert.Equal(notificationId, buffered.Id);
// The payload is a NotificationSubmit carrying the same NotificationId and the
// list / subject / body the script supplied — the shape the forwarder reads.
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(notificationId, payload!.NotificationId);
Assert.Equal("Operators", payload.ListName);
Assert.Equal("Pump alarm", payload.Subject);
Assert.Equal("Pump 3 tripped", payload.Body);
Assert.Equal("Plant.Pump3", payload.SourceInstanceId);
}
[Fact]
public async Task Send_WhenHelperHasSourceScript_StampsItOnTheNotificationSubmit()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceScript: "ScriptActor:MonitorSpeed");
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
// FU3: the executing script name is threaded down and stamped for the audit trail.
Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript);
}
[Fact]
public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload()
{
// Audit Log #23 (ExecutionId Task 5): 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
// onto the site-emitted NotifySend audit row.
var executionId = Guid.NewGuid();
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, executionId: executionId);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(executionId, payload!.OriginExecutionId);
}
[Fact]
public async Task Send_StampsParentExecutionId_OnTheNotificationSubmitPayload()
{
// Audit Log ParentExecutionId (Task 7): 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.
// This is the SAME parent id stamped onto the site-emitted NotifySend row.
var parentExecutionId = Guid.NewGuid();
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, parentExecutionId: parentExecutionId);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(parentExecutionId, payload!.OriginParentExecutionId);
}
[Fact]
public async Task Send_NonRoutedRun_LeavesOriginParentExecutionIdNull()
{
// Non-routed runs have no parent execution — OriginParentExecutionId
// stays null on the NotificationSubmit payload.
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, parentExecutionId: null);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Null(payload!.OriginParentExecutionId);
}
[Fact]
public async Task Send_StampsSourceNode_OnTheNotificationSubmitPayload()
{
// SourceNode-stamping (Task 13): 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
// the Notifications row.
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceNode: "node-a");
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal("node-a", payload!.SourceNode);
}
[Fact]
public async Task Send_NoNodeIdentity_LeavesSourceNodeNull()
{
// Hosts that don't wire INodeIdentityProvider (legacy / tests) pass
// null through. The NotificationSubmit payload's SourceNode stays
// null so the central Notifications row persists NULL rather than
// falling back to a placeholder.
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceNode: null);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Null(payload!.SourceNode);
}
[Fact]
public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, sourceScript: null);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.Null(payload!.SourceScript);
}
[Fact]
public async Task Status_WhenStillBufferedAtSite_ReportsForwarding()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
// Enqueue but never let it forward — the message stays buffered at the site.
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var statusTask = notify.Status(notificationId);
// The status query goes to central; central has no row for an un-forwarded
// notification, so it answers Found: false.
var query = await commProbe.ExpectMsgAsync<NotificationStatusQuery>();
Assert.Equal(notificationId, query.NotificationId);
commProbe.Reply(new NotificationStatusResponse(
query.CorrelationId, Found: false, Status: "Unknown",
RetryCount: 0, LastError: null, DeliveredAt: null));
var status = await statusTask;
// Found: false AND still in the site S&F buffer → the site-local Forwarding state.
Assert.Equal("Forwarding", status.Status);
}
[Fact]
public async Task Status_WhenCentralReportsDelivered_MapsTheCentralResponse()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var deliveredAt = DateTimeOffset.UtcNow;
var statusTask = notify.Status("not-buffered-id");
var query = await commProbe.ExpectMsgAsync<NotificationStatusQuery>();
commProbe.Reply(new NotificationStatusResponse(
query.CorrelationId, Found: true, Status: "Delivered",
RetryCount: 2, LastError: "earlier transient", DeliveredAt: deliveredAt));
var status = await statusTask;
Assert.Equal("Delivered", status.Status);
Assert.Equal(2, status.RetryCount);
Assert.Equal("earlier transient", status.LastError);
Assert.Equal(deliveredAt, status.DeliveredAt);
}
[Fact]
public async Task Status_WhenCentralNotFoundAndNotBuffered_ReportsUnknown()
{
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref);
var statusTask = notify.Status("never-existed-id");
var query = await commProbe.ExpectMsgAsync<NotificationStatusQuery>();
commProbe.Reply(new NotificationStatusResponse(
query.CorrelationId, Found: false, Status: "Unknown",
RetryCount: 0, LastError: null, DeliveredAt: null));
var status = await statusTask;
// Not at central, not in the site buffer → genuinely unknown, NOT Forwarding.
Assert.Equal("Unknown", status.Status);
}
}
@@ -0,0 +1,283 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle C (Task C1): 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
/// best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
/// abort the script's <c>Send</c> — the original <c>NotificationId</c> must
/// still flow back to the caller and the underlying S&amp;F enqueue must still
/// have happened.
/// </summary>
public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
{
/// <summary>
/// In-memory <see cref="IAuditWriter"/> that records every event passed to
/// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
/// catastrophic audit-writer failure that the wrapper must swallow per
/// alog.md §7.
/// </summary>
private sealed class CapturingAuditWriter : IAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Exception? ThrowOnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
if (ThrowOnWrite != null)
{
return Task.FromException(ThrowOnWrite);
}
Events.Add(evt);
return Task.CompletedTask;
}
}
private const string SiteId = "site-7";
private const string InstanceName = "Plant.Pump3";
private const string SourceScript = "ScriptActor:CheckPressure";
private const string ListName = "Operators";
private const string Subject = "Pump alarm";
private const string Body = "Pump 3 tripped";
/// <summary>
/// Audit Log #23: 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();
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _saf;
public NotifySendAuditEmissionTests()
{
var dbName = $"NotifySendAudit_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
var options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10)
};
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
protected override void Dispose(bool disposing)
{
if (disposing)
{
_keepAlive.Dispose();
}
base.Dispose(disposing);
}
private ScriptRuntimeContext.NotifyHelper CreateHelper(
IAuditWriter? auditWriter,
string? sourceScript = SourceScript,
Guid? parentExecutionId = null)
{
// siteCommunicationActor is unused by Send — pass a probe so the helper
// is fully constructed.
var probe = CreateTestProbe();
return new ScriptRuntimeContext.NotifyHelper(
_saf,
probe.Ref,
SiteId,
InstanceName,
sourceScript,
TimeSpan.FromSeconds(3),
NullLogger.Instance,
TestExecutionId,
auditWriter,
parentExecutionId: parentExecutionId);
}
[Fact]
public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
var notificationId = await notify.To(ListName).Send(Subject, Body);
Assert.False(string.IsNullOrEmpty(notificationId));
Assert.Single(writer.Events);
var evt = writer.Events[0];
Assert.Equal(AuditChannel.Notification, evt.Channel);
Assert.Equal(AuditKind.NotifySend, evt.Kind);
Assert.Equal(AuditStatus.Submitted, evt.Status);
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);
Assert.Null(evt.DurationMs);
Assert.Null(evt.HttpStatus);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
}
[Fact]
public async Task Send_PopulatesTarget_AsListName()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = writer.Events[0];
Assert.Equal(ListName, evt.Target);
}
[Fact]
public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = writer.Events[0];
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.
using var doc = JsonDocument.Parse(evt.RequestSummary!);
var root = doc.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
Assert.Equal(Subject, root.GetProperty("subject").GetString());
Assert.Equal(Body, root.GetProperty("body").GetString());
}
[Fact]
public async Task Send_AuditWriter_Throws_OriginalSendStillReturns()
{
var writer = new CapturingAuditWriter
{
ThrowOnWrite = new InvalidOperationException("audit writer down")
};
var notify = CreateHelper(writer);
// The Send call must NOT bubble the audit-writer failure: the script
// contract is that the notification is buffered and the id is returned
// even when the audit pipeline is sick.
var notificationId = await notify.To(ListName).Send(Subject, Body);
Assert.False(string.IsNullOrEmpty(notificationId));
// And the underlying S&F enqueue must still have happened — audit is
// purely additive, never aborts the user-facing action.
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
Assert.Equal(notificationId, buffered!.Id);
Assert.Empty(writer.Events);
}
[Fact]
public async Task Send_Provenance_PopulatedFromContext()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = writer.Events[0];
Assert.Equal(SiteId, evt.SourceSiteId);
Assert.Equal(InstanceName, evt.SourceInstanceId);
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
}
[Fact]
public async Task Send_NotificationIdParsed_AsCorrelationId()
{
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
var notificationId = await notify.To(ListName).Send(Subject, Body);
// NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char
// hex form, which Guid.TryParse accepts. The audit row's CorrelationId
// must round-trip back to the same Guid value (the per-operation
// lifecycle id). ExecutionId carries the per-execution id instead.
Assert.True(Guid.TryParse(notificationId, out var expected),
$"NotificationId '{notificationId}' should be a parseable Guid");
var evt = writer.Events[0];
Assert.NotNull(evt.CorrelationId);
Assert.Equal(expected, evt.CorrelationId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
// Audit Log #23 (ParentExecutionId): null for a non-routed run.
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow()
{
// Audit Log #23 (ParentExecutionId, Task 5): 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();
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer, parentExecutionId: parentExecutionId);
await notify.To(ListName).Send(Subject, Body);
var evt = Assert.Single(writer.Events);
Assert.Equal(parentExecutionId, evt.ParentExecutionId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
}
[Fact]
public async Task Send_NonRoutedRun_ParentExecutionIdIsNull()
{
// A normal (tag/timer) run is not routed — the NotifySend row's
// ParentExecutionId stays null.
var writer = new CapturingAuditWriter();
var notify = CreateHelper(writer);
await notify.To(ListName).Send(Subject, Body);
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues()
{
// Audit is opt-in (mirrors M2 Bundle F behaviour): 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);
var notificationId = await notify.To(ListName).Send(Subject, Body);
Assert.False(string.IsNullOrEmpty(notificationId));
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
}
}
@@ -0,0 +1,308 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// WP-6 (Phase 8): Script sandboxing verification.
/// Adversarial tests that verify forbidden APIs are blocked at compilation time.
/// </summary>
public class SandboxTests
{
private readonly ScriptCompilationService _service;
public SandboxTests()
{
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
}
// ── System.IO forbidden ──
[Fact]
public void Sandbox_FileRead_Blocked()
{
var result = _service.Compile("evil", """System.IO.File.ReadAllText("/etc/passwd")""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.IO"));
}
[Fact]
public void Sandbox_FileWrite_Blocked()
{
var result = _service.Compile("evil", """System.IO.File.WriteAllText("/tmp/hack.txt", "pwned")""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_DirectoryCreate_Blocked()
{
var result = _service.Compile("evil", """System.IO.Directory.CreateDirectory("/tmp/evil")""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_FileStream_Blocked()
{
var result = _service.Compile("evil", """new System.IO.FileStream("/tmp/x", System.IO.FileMode.Create)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_StreamReader_Blocked()
{
var result = _service.Compile("evil", """new System.IO.StreamReader("/tmp/x")""");
Assert.False(result.IsSuccess);
}
// ── Process forbidden ──
[Fact]
public void Sandbox_ProcessStart_Blocked()
{
var result = _service.Compile("evil", """System.Diagnostics.Process.Start("cmd.exe", "/c dir")""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("Process"));
}
[Fact]
public void Sandbox_ProcessStartInfo_Blocked()
{
var code = """
var psi = new System.Diagnostics.Process();
psi.StartInfo.FileName = "bash";
""";
var result = _service.Compile("evil", code);
Assert.False(result.IsSuccess);
}
// ── Threading forbidden (except Tasks/CancellationToken) ──
[Fact]
public void Sandbox_ThreadCreate_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Thread(() => {}).Start()""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.Threading"));
}
[Fact]
public void Sandbox_Mutex_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Mutex()""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_Semaphore_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Semaphore(1, 1)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_TaskDelay_Allowed()
{
// async/await and Tasks are explicitly allowed
var violations = _service.ValidateTrustModel("await System.Threading.Tasks.Task.Delay(100)");
Assert.Empty(violations);
}
[Fact]
public void Sandbox_CancellationToken_Allowed()
{
var violations = _service.ValidateTrustModel(
"var ct = System.Threading.CancellationToken.None;");
Assert.Empty(violations);
}
[Fact]
public void Sandbox_CancellationTokenSource_Allowed()
{
var violations = _service.ValidateTrustModel(
"var cts = new System.Threading.CancellationTokenSource();");
Assert.Empty(violations);
}
// ── Reflection forbidden ──
[Fact]
public void Sandbox_GetType_Reflection_Blocked()
{
var result = _service.Compile("evil",
"""typeof(string).GetMethods(System.Reflection.BindingFlags.NonPublic)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_AssemblyLoad_Blocked()
{
var result = _service.Compile("evil",
"""System.Reflection.Assembly.Load("System.Runtime")""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_ActivatorCreateInstance_ViaReflection_Blocked()
{
var result = _service.Compile("evil",
"""System.Reflection.Assembly.GetExecutingAssembly()""");
Assert.False(result.IsSuccess);
}
// ── Raw network forbidden ──
[Fact]
public void Sandbox_TcpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Sockets.TcpClient("evil.com", 80)""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.Net.Sockets"));
}
[Fact]
public void Sandbox_UdpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Sockets.UdpClient(1234)""");
Assert.False(result.IsSuccess);
}
[Fact]
public void Sandbox_HttpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Http.HttpClient()""");
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Contains("System.Net.Http"));
}
[Fact]
public void Sandbox_HttpRequestMessage_Blocked()
{
var result = _service.Compile("evil",
"""new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, "https://evil.com")""");
Assert.False(result.IsSuccess);
}
// ── Allowed operations ──
[Fact]
public void Sandbox_BasicMath_Allowed()
{
var result = _service.Compile("safe", "Math.Max(1, 2)");
Assert.True(result.IsSuccess);
}
[Fact]
public void Sandbox_LinqOperations_Allowed()
{
var result = _service.Compile("safe",
"new List<int> { 1, 2, 3 }.Where(x => x > 1).Sum()");
Assert.True(result.IsSuccess);
}
[Fact]
public void Sandbox_StringOperations_Allowed()
{
var result = _service.Compile("safe",
"""string.Join(", ", new[] { "a", "b", "c" })""");
Assert.True(result.IsSuccess);
}
[Fact]
public void Sandbox_DateTimeOperations_Allowed()
{
var result = _service.Compile("safe",
"DateTime.UtcNow.AddHours(1).ToString(\"o\")");
Assert.True(result.IsSuccess);
}
// ── Execution timeout ──
[Fact]
public async Task Sandbox_InfiniteLoop_CancelledByToken()
{
// Compile a script that loops forever
var code = """
while (true) {
CancellationToken.ThrowIfCancellationRequested();
}
return null;
""";
var result = _service.Compile("infinite", code);
Assert.True(result.IsSuccess, "Infinite loop compiles but should be cancelled at runtime");
// Execute with a short timeout
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
var globals = new ScriptGlobals
{
Instance = null!,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await result.CompiledScript!.RunAsync(globals, cts.Token);
});
}
[Fact]
public async Task Sandbox_LongRunningScript_TimesOut()
{
// A script that does heavy computation with cancellation checks
var code = """
var sum = 0;
for (var i = 0; i < 100_000_000; i++) {
sum += i;
if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested();
}
return sum;
""";
var result = _service.Compile("heavy", code);
Assert.True(result.IsSuccess);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var globals = new ScriptGlobals
{
Instance = null!,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await result.CompiledScript!.RunAsync(globals, cts.Token);
});
}
// ── Combined adversarial attempts ──
[Fact]
public void Sandbox_MultipleViolationsInOneScript_AllDetected()
{
var code = """
System.IO.File.ReadAllText("/etc/passwd");
System.Diagnostics.Process.Start("cmd");
new System.Net.Sockets.TcpClient();
new System.Net.Http.HttpClient();
""";
var violations = _service.ValidateTrustModel(code);
Assert.True(violations.Count >= 4,
$"Expected at least 4 violations but got {violations.Count}: {string.Join("; ", violations)}");
}
[Fact]
public void Sandbox_UsingDirective_StillDetected()
{
var code = """
// Even with using aliases, the namespace string is still detected
var x = System.IO.Path.GetTempPath();
""";
var violations = _service.ValidateTrustModel(code);
Assert.NotEmpty(violations);
}
}
@@ -0,0 +1,87 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Phase 1 of the script-scope rollout: verify path arithmetic for the new
/// Attributes / Children / Parent accessors. The actor-mediated reads/writes
/// are exercised end-to-end in Phase 2 once flattening carries scope info.
/// </summary>
public class ScopeAccessorTests
{
[Fact]
public void Root_SelfPath_Empty()
{
Assert.Equal("", ScriptScope.Root.SelfPath);
Assert.Null(ScriptScope.Root.ParentPath);
Assert.False(ScriptScope.Root.HasParent);
}
[Fact]
public void CompositionScope_HasParent()
{
var scope = new ScriptScope("TempSensor", "");
Assert.True(scope.HasParent);
Assert.Equal("", scope.ParentPath);
}
[Fact]
public void AttributeAccessor_RootScope_ResolvesBareKey()
{
var acc = new AttributeAccessor(null!, "");
Assert.Equal("Temperature", acc.Resolve("Temperature"));
}
[Fact]
public void AttributeAccessor_ComposedScope_PrependsPath()
{
var acc = new AttributeAccessor(null!, "TempSensor");
Assert.Equal("TempSensor.Temperature", acc.Resolve("Temperature"));
}
[Fact]
public void AttributeAccessor_NestedScope_ChainsPath()
{
var acc = new AttributeAccessor(null!, "Motor.TempSensor");
Assert.Equal("Motor.TempSensor.Temperature", acc.Resolve("Temperature"));
}
[Fact]
public void CompositionAccessor_AttributesShareScope()
{
var comp = new CompositionAccessor(null!, "TempSensor");
Assert.Equal("TempSensor", comp.Path);
Assert.Equal("TempSensor", comp.Attributes.ScopePrefix);
}
[Fact]
public void CompositionAccessor_ResolveScript_PrependsPath()
{
var comp = new CompositionAccessor(null!, "TempSensor");
Assert.Equal("TempSensor.Sample", comp.ResolveScript("Sample"));
}
[Fact]
public void CompositionAccessor_EmptyPath_LeavesScriptNameBare()
{
var comp = new CompositionAccessor(null!, "");
Assert.Equal("Sample", comp.ResolveScript("Sample"));
}
[Fact]
public void ChildrenAccessor_FromRoot_GivesUnpathedChild()
{
var children = new ChildrenAccessor(null!, "");
var temp = children["TempSensor"];
Assert.Equal("TempSensor", temp.Path);
}
[Fact]
public void ChildrenAccessor_FromComposition_PrefixesChild()
{
var children = new ChildrenAccessor(null!, "Motor");
var temp = children["TempSensor"];
Assert.Equal("Motor.TempSensor", temp.Path);
}
}
@@ -0,0 +1,111 @@
using Microsoft.Extensions.Logging.Abstractions;
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.
/// </summary>
public class ScriptCompilationServiceTests
{
private readonly ScriptCompilationService _service;
public ScriptCompilationServiceTests()
{
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
}
[Fact]
public void Compile_ValidScript_Succeeds()
{
var result = _service.Compile("test", "1 + 1");
Assert.True(result.IsSuccess);
Assert.NotNull(result.CompiledScript);
Assert.Empty(result.Errors);
}
[Fact]
public void Compile_InvalidSyntax_ReturnsErrors()
{
var result = _service.Compile("bad", "this is not valid C# {{{");
Assert.False(result.IsSuccess);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void ValidateTrustModel_SystemIO_Forbidden()
{
var violations = _service.ValidateTrustModel("System.IO.File.ReadAllText(\"test\")");
Assert.NotEmpty(violations);
Assert.Contains(violations, v => v.Contains("System.IO"));
}
[Fact]
public void ValidateTrustModel_Process_Forbidden()
{
var violations = _service.ValidateTrustModel(
"System.Diagnostics.Process.Start(\"cmd\")");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_Reflection_Forbidden()
{
var violations = _service.ValidateTrustModel(
"typeof(string).GetType().GetMethods(System.Reflection.BindingFlags.Public)");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_Sockets_Forbidden()
{
var violations = _service.ValidateTrustModel(
"new System.Net.Sockets.TcpClient()");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_HttpClient_Forbidden()
{
var violations = _service.ValidateTrustModel(
"new System.Net.Http.HttpClient()");
Assert.NotEmpty(violations);
}
[Fact]
public void ValidateTrustModel_AsyncAwait_Allowed()
{
// System.Threading.Tasks should be allowed (async/await support)
var violations = _service.ValidateTrustModel(
"await System.Threading.Tasks.Task.Delay(100)");
Assert.Empty(violations);
}
[Fact]
public void ValidateTrustModel_CancellationToken_Allowed()
{
var violations = _service.ValidateTrustModel(
"System.Threading.CancellationToken.None");
Assert.Empty(violations);
}
[Fact]
public void ValidateTrustModel_CleanCode_NoViolations()
{
var code = @"
var x = 1 + 2;
var list = new List<int> { 1, 2, 3 };
var sum = list.Sum();
sum";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
[Fact]
public void Compile_ForbiddenApi_FailsValidation()
{
var result = _service.Compile("evil", "System.IO.File.Delete(\"/tmp/test\")");
Assert.False(result.IsSuccess);
Assert.NotEmpty(result.Errors);
}
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
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
/// 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
{
[Fact]
public async Task Scheduler_RunsWork_OffTheThreadPool()
{
using var scheduler = new ScriptExecutionScheduler(2);
bool wasThreadPoolThread = true;
string? threadName = null;
await Task.Factory.StartNew(() =>
{
wasThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
threadName = Thread.CurrentThread.Name;
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler);
Assert.False(wasThreadPoolThread,
"Script work must not run on a shared thread-pool thread.");
Assert.StartsWith("script-execution-", threadName);
}
[Fact]
public void Scheduler_RespectsConfiguredThreadCount()
{
using var scheduler = new ScriptExecutionScheduler(4);
Assert.Equal(4, scheduler.MaximumConcurrencyLevel);
}
[Fact]
public void Scheduler_Shared_ReturnsSameInstanceForOptions()
{
var options = new SiteRuntimeOptions { ScriptExecutionThreadCount = 3 };
var a = ScriptExecutionScheduler.Shared(options);
var b = ScriptExecutionScheduler.Shared(options);
Assert.Same(a, b);
}
}
@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging.Abstractions;
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.
/// </summary>
public class SharedScriptLibraryTests
{
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _library;
public SharedScriptLibraryTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_library = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
[Fact]
public void CompileAndRegister_ValidScript_Succeeds()
{
var result = _library.CompileAndRegister("add", "1 + 2");
Assert.True(result);
Assert.True(_library.Contains("add"));
}
[Fact]
public void CompileAndRegister_InvalidScript_ReturnsFalse()
{
var result = _library.CompileAndRegister("bad", "this is not valid {{{");
Assert.False(result);
Assert.False(_library.Contains("bad"));
}
[Fact]
public void CompileAndRegister_ForbiddenApi_ReturnsFalse()
{
var result = _library.CompileAndRegister("evil", "System.IO.File.Delete(\"/tmp\")");
Assert.False(result);
}
[Fact]
public void CompileAndRegister_Replaces_ExistingScript()
{
_library.CompileAndRegister("calc", "1 + 1");
_library.CompileAndRegister("calc", "2 + 2");
Assert.True(_library.Contains("calc"));
// Should have only one entry
Assert.Equal(1, _library.GetRegisteredScriptNames().Count(n => n == "calc"));
}
[Fact]
public void Remove_RegisteredScript_ReturnsTrue()
{
_library.CompileAndRegister("temp", "42");
Assert.True(_library.Remove("temp"));
Assert.False(_library.Contains("temp"));
}
[Fact]
public void Remove_NonexistentScript_ReturnsFalse()
{
Assert.False(_library.Remove("nonexistent"));
}
[Fact]
public void GetRegisteredScriptNames_ReturnsAllNames()
{
_library.CompileAndRegister("a", "1");
_library.CompileAndRegister("b", "2");
_library.CompileAndRegister("c", "3");
var names = _library.GetRegisteredScriptNames();
Assert.Equal(3, names.Count);
Assert.Contains("a", names);
Assert.Contains("b", names);
Assert.Contains("c", names);
}
[Fact]
public async Task ExecuteAsync_NonexistentScript_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(
() => _library.ExecuteAsync("missing", null!));
}
}
@@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
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
/// <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
/// the id is unknown.
/// </summary>
public class TrackingApiTests
{
private static ScriptRuntimeContext.TrackingHelper CreateHelper(
IOperationTrackingStore? store)
{
return new ScriptRuntimeContext.TrackingHelper(store, NullLogger.Instance);
}
[Fact]
public async Task Status_UnknownId_ReturnsNull()
{
var store = new Mock<IOperationTrackingStore>();
store
.Setup(s => s.GetStatusAsync(It.IsAny<TrackedOperationId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((TrackingStatusSnapshot?)null);
var helper = CreateHelper(store.Object);
var result = await helper.Status(TrackedOperationId.New());
Assert.Null(result);
}
[Fact]
public async Task Status_KnownId_ReturnsLatestSnapshot()
{
var id = TrackedOperationId.New();
var expected = new TrackingStatusSnapshot(
Id: id,
Kind: "ApiCallCached",
TargetSummary: "ERP.GetOrder",
Status: "Delivered",
RetryCount: 2,
LastError: null,
HttpStatus: 200,
CreatedAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
TerminalAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
SourceInstanceId: "Plant.Pump42",
SourceScript: "ScriptActor:OnTick",
SourceNode: null);
var store = new Mock<IOperationTrackingStore>();
store
.Setup(s => s.GetStatusAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var helper = CreateHelper(store.Object);
var result = await helper.Status(id);
Assert.NotNull(result);
Assert.Equal(expected, result);
}
[Fact]
public async Task Status_NoStoreWired_Throws()
{
var helper = CreateHelper(store: null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => helper.Status(TrackedOperationId.New()));
}
}
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Logging.Abstractions;
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.
/// 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.
/// </summary>
public class TrustModelSemanticTests
{
private readonly ScriptCompilationService _service =
new(NullLogger<ScriptCompilationService>.Instance);
// ── Bypass cases (under-inclusive substring scan would MISS these) ──
[Fact]
public void TrustModel_GlobalQualifiedForbiddenType_IsDetected()
{
// `global::`-prefixed name — the literal "System.IO" substring is still present
// here, but the resolved-symbol approach catches it regardless of spelling.
var violations = _service.ValidateTrustModel(
"global::System.IO.File.ReadAllText(\"/etc/passwd\")");
Assert.NotEmpty(violations);
}
[Fact]
public void TrustModel_ForbiddenTypeViaUsingAlias_IsDetected()
{
// A using-alias hides the forbidden namespace from a substring scan entirely:
// the script body never writes "System.IO". Semantic resolution still sees that
// the alias resolves to System.IO.File.
var code = """
using F = System.IO.File;
F.ReadAllText("/etc/passwd");
""";
var violations = _service.ValidateTrustModel(code);
Assert.NotEmpty(violations);
Assert.Contains(violations, v => v.Contains("System.IO"));
}
// ── False-positive cases (over-inclusive substring scan would WRONGLY flag these) ──
[Fact]
public void TrustModel_ForbiddenNamespaceInStringLiteral_IsNotFlagged()
{
// "System.IO" appears only inside a string literal — not an API reference.
var violations = _service.ValidateTrustModel(
"var label = \"System.IO is blocked\"; return label;");
Assert.Empty(violations);
}
[Fact]
public void TrustModel_ForbiddenNamespaceInComment_IsNotFlagged()
{
var code = """
// This script does not use System.IO or System.Reflection at all.
var x = 1 + 2;
return x;
""";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
[Fact]
public void TrustModel_UnrelatedIdentifierContainingForbiddenSubstring_IsNotFlagged()
{
// A local variable whose name merely contains "Threading" is harmless.
var code = """
var ProcessThreadingCount = 5;
return ProcessThreadingCount + 1;
""";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
// ── Allowed exceptions still resolve correctly ──
[Fact]
public void TrustModel_TaskAndCancellationToken_RemainAllowed()
{
var code = """
var cts = new System.Threading.CancellationTokenSource();
await System.Threading.Tasks.Task.Delay(1, cts.Token);
return 0;
""";
var violations = _service.ValidateTrustModel(code);
Assert.Empty(violations);
}
}
@@ -0,0 +1,118 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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).
/// </summary>
public class SiteStreamManagerTests : TestKit, IDisposable
{
private readonly SiteStreamManager _streamManager;
public SiteStreamManagerTests()
{
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
_streamManager = new SiteStreamManager(
options, NullLogger<SiteStreamManager>.Instance);
_streamManager.Initialize(Sys);
}
void IDisposable.Dispose()
{
Shutdown();
}
[Fact]
public void Subscribe_CreatesSubscription()
{
var probe = CreateTestProbe();
var id = _streamManager.Subscribe("Pump1", probe.Ref);
Assert.NotNull(id);
Assert.Equal(1, _streamManager.SubscriptionCount);
}
[Fact]
public void Unsubscribe_RemovesSubscription()
{
var probe = CreateTestProbe();
var id = _streamManager.Subscribe("Pump1", probe.Ref);
Assert.True(_streamManager.Unsubscribe(id));
Assert.Equal(0, _streamManager.SubscriptionCount);
}
[Fact]
public void Unsubscribe_InvalidId_ReturnsFalse()
{
Assert.False(_streamManager.Unsubscribe("nonexistent"));
}
[Fact]
public void PublishAttributeValueChanged_ForwardsToSubscriber()
{
var probe = CreateTestProbe();
_streamManager.Subscribe("Pump1", probe.Ref);
var changed = new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow);
_streamManager.PublishAttributeValueChanged(changed);
var received = probe.ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(3));
Assert.Equal("Pump1", received.InstanceUniqueName);
Assert.Equal("Temperature", received.AttributeName);
}
[Fact]
public void PublishAlarmStateChanged_ForwardsToSubscriber()
{
var probe = CreateTestProbe();
_streamManager.Subscribe("Pump1", probe.Ref);
var changed = new AlarmStateChanged(
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow);
_streamManager.PublishAlarmStateChanged(changed);
var received = probe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3));
Assert.Equal("Pump1", received.InstanceUniqueName);
Assert.Equal(AlarmState.Active, received.State);
}
[Fact]
public void PublishAttributeValueChanged_FiltersbyInstance()
{
var probe1 = CreateTestProbe();
var probe2 = CreateTestProbe();
_streamManager.Subscribe("Pump1", probe1.Ref);
_streamManager.Subscribe("Pump2", probe2.Ref);
var changed = new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "100", "Good", DateTimeOffset.UtcNow);
_streamManager.PublishAttributeValueChanged(changed);
// Pump1 subscriber should receive
probe1.ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(3));
// Pump2 subscriber should NOT receive
probe2.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void RemoveSubscriber_RemovesAllSubscriptionsForActor()
{
var probe = CreateTestProbe();
_streamManager.Subscribe("Pump1", probe.Ref);
_streamManager.Subscribe("Pump2", probe.Ref);
Assert.Equal(2, _streamManager.SubscriptionCount);
_streamManager.RemoveSubscriber(probe.Ref);
Assert.Equal(0, _streamManager.SubscriptionCount);
}
}
@@ -0,0 +1,552 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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
/// site-local <see cref="OperationTrackingStore"/>. Each test uses a unique
/// shared-cache in-memory SQLite database so the store and the verifier share
/// the same store without touching disk.
/// </summary>
public class OperationTrackingStoreTests
{
private static (OperationTrackingStore store, string dataSource) CreateStore(
string testName)
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions
{
ConnectionString = connectionString,
};
var store = new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
return (store, dataSource);
}
private static SqliteConnection OpenVerifierConnection(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
return connection;
}
[Fact]
public void Constructor_CreatesOperationTracking_SchemaOnFirstUse()
{
var (store, dataSource) = CreateStore(nameof(Constructor_CreatesOperationTracking_SchemaOnFirstUse));
using (store)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA table_info(OperationTracking);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk, int NotNull)>();
while (reader.Read())
{
columns.Add((reader.GetString(1), reader.GetInt32(5), reader.GetInt32(3)));
}
var expected = new[]
{
"TrackedOperationId", "Kind", "TargetSummary", "Status",
"RetryCount", "LastError", "HttpStatus", "CreatedAtUtc",
"UpdatedAtUtc", "TerminalAtUtc", "SourceInstanceId", "SourceScript",
"SourceNode",
};
Assert.Equal(
expected.OrderBy(n => n),
columns.Select(c => c.Name).OrderBy(n => n));
var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList();
Assert.Single(pkColumns);
Assert.Equal("TrackedOperationId", pkColumns[0]);
}
}
[Fact]
public void Initialize_creates_OperationTracking_with_SourceNode_column()
{
var (store, dataSource) = CreateStore(nameof(Initialize_creates_OperationTracking_with_SourceNode_column));
using (store)
{
using var connection = OpenVerifierConnection(dataSource);
Assert.True(
ColumnExists(connection, "SourceNode"),
"Fresh OperationTracking schema must include the SourceNode column.");
}
}
/// <summary>
/// The pre-SourceNode <c>OperationTracking</c> schema — the 12-column
/// CREATE TABLE that has the original source-provenance columns
/// (<c>SourceInstanceId</c>, <c>SourceScript</c>) but is WITHOUT
/// <c>SourceNode</c>. A deployment that ran before the SourceNode
/// stamping work already has an on-disk <c>tracking.db</c> in exactly
/// this shape, and <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreSourceNodeSchema = """
CREATE TABLE IF NOT EXISTS OperationTracking (
TrackedOperationId TEXT NOT NULL PRIMARY KEY,
Kind TEXT NOT NULL,
TargetSummary TEXT NULL,
Status TEXT NOT NULL,
RetryCount INTEGER NOT NULL DEFAULT 0,
LastError TEXT NULL,
HttpStatus INTEGER NULL,
CreatedAtUtc TEXT NOT NULL,
UpdatedAtUtc TEXT NOT NULL,
TerminalAtUtc TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated
ON OperationTracking (Status, UpdatedAtUtc);
""";
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('OperationTracking') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
private static OperationTrackingStore CreateStoreOver(string dataSource)
{
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions { ConnectionString = connectionString };
return new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
}
[Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
{
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-SourceNode deployment: tracking.db already exists with the
// 12-column schema and NO SourceNode column.
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "SourceInstanceId"));
Assert.True(ColumnExists(seedConnection, "SourceScript"));
Assert.False(ColumnExists(seedConnection, "SourceNode"));
// Upgrade: a post-branch OperationTrackingStore opens the same database.
// Its InitializeSchema must ALTER the missing SourceNode column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table.
await using (var store = CreateStoreOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "SourceNode"),
"OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table.");
// A RecordEnqueueAsync binding $sourceNode must now succeed; without
// the ALTER it would fail with "no such column: SourceNode".
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(
id,
kind: "ApiCallCached",
targetSummary: "ERP.GetOrder",
sourceInstanceId: "inst-1",
sourceScript: "ScriptActor:OnTick",
sourceNode: "node-a");
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal("node-a", snapshot!.SourceNode);
}
// Idempotency: a second store over the now-upgraded DB must not error
// (the probe sees SourceNode already present and skips the ALTER).
await using (var storeAgain = CreateStoreOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "SourceNode"));
}
}
[Fact]
public async Task RecordEnqueueAsync_persists_SourceNode()
{
var (store, _) = CreateStore(nameof(RecordEnqueueAsync_persists_SourceNode));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(
id,
kind: nameof(AuditKind.ApiCallCached),
targetSummary: "ERP.GetOrder",
sourceInstanceId: "Plant.Pump42",
sourceScript: "ScriptActor:OnTick",
sourceNode: "node-a");
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal("node-a", snapshot!.SourceNode);
}
[Fact]
public async Task RecordEnqueueAsync_persists_null_SourceNode()
{
var (store, _) = CreateStore(nameof(RecordEnqueueAsync_persists_null_SourceNode));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(
id,
kind: nameof(AuditKind.ApiCallCached),
targetSummary: "ERP.GetOrder",
sourceInstanceId: null,
sourceScript: null,
sourceNode: null);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Null(snapshot!.SourceNode);
}
[Fact]
public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero()
{
var (store, dataSource) = CreateStore(nameof(RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero));
await using var _ = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(
id,
kind: nameof(AuditKind.ApiCallCached),
targetSummary: "ERP.GetOrder",
sourceInstanceId: "Plant.Pump42",
sourceScript: "ScriptActor:OnTick",
sourceNode: null);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(id, snapshot!.Id);
Assert.Equal(nameof(AuditKind.ApiCallCached), snapshot.Kind);
Assert.Equal("ERP.GetOrder", snapshot.TargetSummary);
Assert.Equal(nameof(AuditStatus.Submitted), snapshot.Status);
Assert.Equal(0, snapshot.RetryCount);
Assert.Null(snapshot.LastError);
Assert.Null(snapshot.HttpStatus);
Assert.Null(snapshot.TerminalAtUtc);
Assert.Equal("Plant.Pump42", snapshot.SourceInstanceId);
Assert.Equal("ScriptActor:OnTick", snapshot.SourceScript);
Assert.Equal(DateTimeKind.Utc, snapshot.CreatedAtUtc.Kind);
Assert.Equal(DateTimeKind.Utc, snapshot.UpdatedAtUtc.Kind);
}
[Fact]
public async Task RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins()
{
var (store, _) = CreateStore(nameof(RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick", sourceNode: null);
await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other", sourceNode: null);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
// First-write-wins: the second enqueue is ignored — Target/Source stay first.
Assert.Equal("ERP.GetOrder", snapshot!.TargetSummary);
Assert.Equal("Plant.Pump42", snapshot.SourceInstanceId);
Assert.Equal("ScriptActor:OnTick", snapshot.SourceScript);
Assert.Equal(nameof(AuditStatus.Submitted), snapshot.Status);
Assert.Equal(0, snapshot.RetryCount);
}
[Fact]
public async Task RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow()
{
var (store, _) = CreateStore(nameof(RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
await store.RecordAttemptAsync(
id,
status: nameof(AuditStatus.Attempted),
retryCount: 1,
lastError: "HTTP 503 from ERP",
httpStatus: 503);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(nameof(AuditStatus.Attempted), snapshot!.Status);
Assert.Equal(1, snapshot.RetryCount);
Assert.Equal("HTTP 503 from ERP", snapshot.LastError);
Assert.Equal(503, snapshot.HttpStatus);
Assert.Null(snapshot.TerminalAtUtc);
// UpdatedAtUtc advances past CreatedAtUtc.
Assert.True(snapshot.UpdatedAtUtc >= snapshot.CreatedAtUtc);
}
[Fact]
public async Task RecordAttemptAsync_OnTerminalRow_IsNoOp()
{
var (store, _) = CreateStore(nameof(RecordAttemptAsync_OnTerminalRow_IsNoOp));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
await store.RecordTerminalAsync(
id,
status: nameof(AuditStatus.Delivered),
lastError: null,
httpStatus: 200);
var terminalSnapshot = await store.GetStatusAsync(id);
Assert.NotNull(terminalSnapshot);
Assert.NotNull(terminalSnapshot!.TerminalAtUtc);
// Late attempt telemetry must NOT overwrite the terminal row.
await store.RecordAttemptAsync(
id,
status: nameof(AuditStatus.Attempted),
retryCount: 5,
lastError: "late attempt",
httpStatus: 500);
var afterLate = await store.GetStatusAsync(id);
Assert.NotNull(afterLate);
Assert.Equal(nameof(AuditStatus.Delivered), afterLate!.Status);
Assert.Equal(0, afterLate.RetryCount);
Assert.Null(afterLate.LastError);
Assert.Equal(200, afterLate.HttpStatus);
Assert.NotNull(afterLate.TerminalAtUtc);
}
[Fact]
public async Task RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet()
{
var (store, _) = CreateStore(nameof(RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
var beforeTerminal = DateTime.UtcNow;
await store.RecordTerminalAsync(
id,
status: nameof(AuditStatus.Parked),
lastError: "HTTP 503 (max retries)",
httpStatus: 503);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(nameof(AuditStatus.Parked), snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
Assert.Equal(DateTimeKind.Utc, snapshot.TerminalAtUtc!.Value.Kind);
Assert.True(snapshot.TerminalAtUtc >= beforeTerminal.AddSeconds(-1));
Assert.Equal("HTTP 503 (max retries)", snapshot.LastError);
Assert.Equal(503, snapshot.HttpStatus);
}
[Fact]
public async Task GetStatusAsync_Unknown_ReturnsNull()
{
var (store, _) = CreateStore(nameof(GetStatusAsync_Unknown_ReturnsNull));
await using var _store = store;
var unknown = TrackedOperationId.New();
var snapshot = await store.GetStatusAsync(unknown);
Assert.Null(snapshot);
}
[Fact]
public async Task GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts()
{
var (store, _) = CreateStore(nameof(GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 1, "first failure", 503);
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 2, "second failure", 503);
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 3, "third failure", 504);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(3, snapshot!.RetryCount);
Assert.Equal("third failure", snapshot.LastError);
Assert.Equal(504, snapshot.HttpStatus);
}
[Fact]
public async Task PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal()
{
var (store, dataSource) = CreateStore(nameof(PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal));
await using var _store = store;
// Three rows:
// (a) terminal, old → should be purged
// (b) terminal, fresh → should be kept
// (c) non-terminal, ancient CreatedAt → should be kept (no TerminalAtUtc)
var aId = TrackedOperationId.New();
var bId = TrackedOperationId.New();
var cId = TrackedOperationId.New();
await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null, sourceNode: null);
await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null, sourceNode: null);
await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null, sourceNode: null);
await store.RecordTerminalAsync(aId, nameof(AuditStatus.Delivered), null, 200);
await store.RecordTerminalAsync(bId, nameof(AuditStatus.Delivered), null, 200);
// Backdate the (a) row's TerminalAtUtc to 30 days ago via a direct UPDATE
// — RecordTerminalAsync stamps DateTime.UtcNow which we cannot inject.
// The verifier connection shares the same in-memory store thanks to
// mode=memory&cache=shared.
using (var connection = OpenVerifierConnection(dataSource))
using (var cmd = connection.CreateCommand())
{
cmd.CommandText =
"UPDATE OperationTracking SET TerminalAtUtc = $old WHERE TrackedOperationId = $id;";
cmd.Parameters.AddWithValue("$old", DateTime.UtcNow.AddDays(-30).ToString("o"));
cmd.Parameters.AddWithValue("$id", aId.ToString());
cmd.ExecuteNonQuery();
}
// Purge anything terminal older than 7 days.
var threshold = DateTime.UtcNow.AddDays(-7);
await store.PurgeTerminalAsync(threshold);
Assert.Null(await store.GetStatusAsync(aId)); // purged
Assert.NotNull(await store.GetStatusAsync(bId)); // kept (recent terminal)
Assert.NotNull(await store.GetStatusAsync(cId)); // kept (non-terminal)
}
// ── SiteRuntime-024: read/write split + sync-safe Dispose ──────────────
[Fact]
public async Task SR024_ConcurrentReads_DoNotBlockOnInFlightWrite()
{
// Regression test for SiteRuntime-024 (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
// the write gate at all — so they should run concurrently with a write.
//
// The test seeds a row, then issues many parallel reads while a write is
// also in flight. We assert the reads return successfully (a regression
// would either deadlock the test runner or take far longer than the gate
// would have allowed any single read). The actual timing-comparison
// assertion would be flaky in CI; this test asserts only correctness +
// forward progress.
var (store, _) = CreateStore(nameof(SR024_ConcurrentReads_DoNotBlockOnInFlightWrite));
await using (store)
{
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(
id,
kind: "ApiCallCached",
targetSummary: "ERP.GetOrder",
sourceInstanceId: null,
sourceScript: null,
sourceNode: "node-a");
// Fire 10 concurrent reads + a write in parallel; all must complete.
var readTasks = Enumerable.Range(0, 10)
.Select(_ => store.GetStatusAsync(id))
.ToArray();
var writeTask = store.RecordAttemptAsync(
id, status: "Retrying", retryCount: 1, lastError: "transient", httpStatus: 503);
await Task.WhenAll(readTasks);
await writeTask;
foreach (var t in readTasks)
{
Assert.NotNull(await t);
}
}
}
[Fact]
public async Task SR024_SyncDispose_DoesNotDeadlock_WhenInvokedFromFreshThread()
{
// Regression test for SiteRuntime-024 (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
// synchronously without acquiring the gate.
var (store, _) = CreateStore(nameof(SR024_SyncDispose_DoesNotDeadlock_WhenInvokedFromFreshThread));
// Seed a row so the store has live state when disposed.
await store.RecordEnqueueAsync(
TrackedOperationId.New(),
kind: "ApiCallCached",
targetSummary: "ERP.GetOrder",
sourceInstanceId: null,
sourceScript: null,
sourceNode: "node-a");
var disposeReturned = new TaskCompletionSource<bool>();
var disposeThread = new Thread(() =>
{
try
{
store.Dispose();
disposeReturned.SetResult(true);
}
catch (Exception ex)
{
disposeReturned.SetException(ex);
}
}) { IsBackground = true };
disposeThread.Start();
// 5s ceiling — if Dispose deadlocks, the test fails with TimeoutException.
var completed = await Task.WhenAny(
disposeReturned.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Same(disposeReturned.Task, completed);
Assert.True(await disposeReturned.Task);
}
[Fact]
public async Task SR024_AsyncDispose_DoesNotDeadlock_AndIsIdempotent()
{
// The async path must also tolerate Dispose() being called afterwards
// (host shutdown's standard pattern). The _disposeState exchange should
// short-circuit the second call.
var (store, _) = CreateStore(nameof(SR024_AsyncDispose_DoesNotDeadlock_AndIsIdempotent));
await store.RecordEnqueueAsync(
TrackedOperationId.New(),
kind: "ApiCallCached",
targetSummary: "ERP.GetOrder",
sourceInstanceId: null,
sourceScript: null,
sourceNode: "node-a");
await store.DisposeAsync();
// Second call must be a no-op, not throw.
store.Dispose();
// And a third async — also a no-op.
await store.DisposeAsync();
}
}
@@ -0,0 +1,6 @@
// Phase 3A tests are in:
// - Persistence/SiteStorageServiceTests.cs
// - Actors/InstanceActorTests.cs
// - Actors/DeploymentManagerActorTests.cs
// - NegativeTests.cs
// - Integration/FailoverIntegrationTests.cs
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.Streams.TestKit" />
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
</ItemGroup>
</Project>