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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
+115
@@ -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);
|
||||
}
|
||||
}
|
||||
+248
@@ -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);
|
||||
}
|
||||
}
|
||||
+217
@@ -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));
|
||||
}
|
||||
}
|
||||
+187
@@ -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<,>(_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);
|
||||
}
|
||||
}
|
||||
+223
@@ -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);
|
||||
}
|
||||
}
|
||||
+216
@@ -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);
|
||||
}
|
||||
}
|
||||
+395
@@ -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");
|
||||
}
|
||||
}
|
||||
+285
@@ -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);
|
||||
}
|
||||
}
|
||||
+638
@@ -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&F buffering, <c>WasBuffered=false</c>),
|
||||
/// the S&F retry loop never engages and the
|
||||
/// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call
|
||||
/// helper itself must therefore emit the terminal lifecycle rows —
|
||||
/// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c>
|
||||
/// forever and the audit log would be missing the <c>Attempted</c> /
|
||||
/// <c>CachedResolve</c> pair the M3 contract requires.
|
||||
///
|
||||
/// 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&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&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);
|
||||
}
|
||||
}
|
||||
+344
@@ -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);
|
||||
}
|
||||
}
|
||||
+283
@@ -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&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);
|
||||
}
|
||||
}
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+552
@@ -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
|
||||
+32
@@ -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>
|
||||
Reference in New Issue
Block a user