Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging

Communication Layer (WP-1–5):
- 8 message patterns with correlation IDs, per-pattern timeouts
- Central/Site communication actors, transport heartbeat config
- Connection failure handling (no central buffering, debug streams killed)

Data Connection Layer (WP-6–14, WP-34):
- Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting)
- OPC UA + LmxProxy adapters behind IDataConnection
- Auto-reconnect, bad quality propagation, transparent re-subscribe
- Write-back, tag path resolution with retry, health reporting
- Protocol extensibility via DataConnectionFactory

Site Runtime (WP-15–25, WP-32–33):
- ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher)
- AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state)
- SharedScriptLibrary (inline execution), ScriptRuntimeContext (API)
- ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout)
- Recursion limit (default 10), call direction enforcement
- SiteStreamManager (per-subscriber bounded buffers, fire-and-forget)
- Debug view backend (snapshot + stream), concurrency serialization
- Local artifact storage (4 SQLite tables)

Health Monitoring (WP-26–28):
- SiteHealthCollector (thread-safe counters, connection state)
- HealthReportSender (30s interval, monotonic sequence numbers)
- CentralHealthAggregator (offline detection 60s, online recovery)

Site Event Logging (WP-29–31):
- SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC)
- EventLogPurgeService (30-day retention, 1GB cap)
- EventLogQueryService (filters, keyword search, keyset pagination)

541 tests pass, zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 20:57:25 -04:00
parent a3bf0c43f3
commit 389f5a0378
97 changed files with 8308 additions and 127 deletions

View File

@@ -0,0 +1,105 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using ScadaLink.Commons.Messages.Communication;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.Communication.Actors;
namespace ScadaLink.Communication.Tests;
/// <summary>
/// WP-4: Tests for CentralCommunicationActor message routing.
/// WP-5: Tests for connection failure and failover handling.
/// </summary>
public class CentralCommunicationActorTests : TestKit
{
public CentralCommunicationActorTests()
: base(@"akka.loglevel = DEBUG")
{
}
[Fact]
public void RegisterSite_AllowsMessageRouting()
{
var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor()));
// Register a site pointing to the test probe
var probe = CreateTestProbe();
centralActor.Tell(new RegisterSite("site1", probe.Ref.Path.ToString()));
// Send a message to the site
var command = new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
centralActor.Tell(new SiteEnvelope("site1", command));
// The probe should receive the inner message (not the envelope)
probe.ExpectMsg<DeployInstanceCommand>(msg => msg.DeploymentId == "dep1");
}
[Fact]
public void UnregisteredSite_MessageIsDropped()
{
var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor()));
var command = new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
centralActor.Tell(new SiteEnvelope("unknown-site", command));
// No crash, no response — the ask will timeout on the caller side
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void ConnectionLost_DebugStreamsKilled()
{
var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor()));
var siteProbe = CreateTestProbe();
// Register site
centralActor.Tell(new RegisterSite("site1", siteProbe.Ref.Path.ToString()));
// Subscribe to debug view (this tracks the subscription)
var subscriberProbe = CreateTestProbe();
var subRequest = new SubscribeDebugViewRequest("inst1", "corr-123");
centralActor.Tell(new SiteEnvelope("site1", subRequest), subscriberProbe.Ref);
// Simulate site disconnection
centralActor.Tell(new ConnectionStateChanged("site1", false, DateTimeOffset.UtcNow));
// The subscriber should receive a DebugStreamTerminated notification
subscriberProbe.ExpectMsg<DebugStreamTerminated>(
msg => msg.SiteId == "site1" && msg.CorrelationId == "corr-123");
}
[Fact]
public void ConnectionLost_SiteSelectionRemoved()
{
var centralActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor()));
var siteProbe = CreateTestProbe();
centralActor.Tell(new RegisterSite("site1", siteProbe.Ref.Path.ToString()));
// Disconnect
centralActor.Tell(new ConnectionStateChanged("site1", false, DateTimeOffset.UtcNow));
// Sending a message to the disconnected site should be dropped
centralActor.Tell(new SiteEnvelope("site1",
new DeployInstanceCommand("dep2", "inst2", "hash2", "{}", "admin", DateTimeOffset.UtcNow)));
siteProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void Heartbeat_ForwardedToParent()
{
var parentProbe = CreateTestProbe();
var centralActor = parentProbe.ChildActorOf(
Props.Create(() => new CentralCommunicationActor()));
var heartbeat = new HeartbeatMessage("site1", "host1", true, DateTimeOffset.UtcNow);
centralActor.Tell(heartbeat);
parentProbe.ExpectMsg<HeartbeatMessage>(msg => msg.SiteId == "site1");
}
}

View File

@@ -0,0 +1,61 @@
namespace ScadaLink.Communication.Tests;
/// <summary>
/// WP-2: Tests for per-pattern timeout configuration.
/// </summary>
public class CommunicationOptionsTests
{
[Fact]
public void DefaultTimeouts_AreReasonable()
{
var options = new CommunicationOptions();
Assert.Equal(TimeSpan.FromMinutes(2), options.DeploymentTimeout);
Assert.Equal(TimeSpan.FromSeconds(30), options.LifecycleTimeout);
Assert.Equal(TimeSpan.FromMinutes(1), options.ArtifactDeploymentTimeout);
Assert.Equal(TimeSpan.FromSeconds(30), options.QueryTimeout);
Assert.Equal(TimeSpan.FromSeconds(30), options.IntegrationTimeout);
Assert.Equal(TimeSpan.FromSeconds(10), options.DebugViewTimeout);
Assert.Equal(TimeSpan.FromSeconds(10), options.HealthReportTimeout);
}
[Fact]
public void TransportHeartbeat_HasExplicitDefaults()
{
var options = new CommunicationOptions();
// WP-3: Transport heartbeat is explicitly configured, not framework defaults
Assert.Equal(TimeSpan.FromSeconds(5), options.TransportHeartbeatInterval);
Assert.Equal(TimeSpan.FromSeconds(15), options.TransportFailureThreshold);
}
[Fact]
public void DeploymentTimeout_IsLongestPattern()
{
var options = new CommunicationOptions();
Assert.True(options.DeploymentTimeout > options.LifecycleTimeout);
Assert.True(options.DeploymentTimeout > options.QueryTimeout);
Assert.True(options.DeploymentTimeout > options.IntegrationTimeout);
}
[Fact]
public void AllTimeouts_AreConfigurable()
{
var options = new CommunicationOptions
{
DeploymentTimeout = TimeSpan.FromMinutes(5),
LifecycleTimeout = TimeSpan.FromMinutes(1),
ArtifactDeploymentTimeout = TimeSpan.FromMinutes(3),
QueryTimeout = TimeSpan.FromMinutes(1),
IntegrationTimeout = TimeSpan.FromMinutes(1),
DebugViewTimeout = TimeSpan.FromSeconds(30),
HealthReportTimeout = TimeSpan.FromSeconds(30),
TransportHeartbeatInterval = TimeSpan.FromSeconds(2),
TransportFailureThreshold = TimeSpan.FromSeconds(10)
};
Assert.Equal(TimeSpan.FromMinutes(5), options.DeploymentTimeout);
Assert.Equal(TimeSpan.FromSeconds(2), options.TransportHeartbeatInterval);
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace ScadaLink.Communication.Tests;
/// <summary>
/// WP-2: Tests for CommunicationService initialization and state.
/// </summary>
public class CommunicationServiceTests
{
[Fact]
public async Task BeforeInitialization_ThrowsOnUsage()
{
var options = Options.Create(new CommunicationOptions());
var logger = NullLogger<CommunicationService>.Instance;
var service = new CommunicationService(options, logger);
// CommunicationService requires SetCommunicationActor before use
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.DeployInstanceAsync("site1",
new Commons.Messages.Deployment.DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow)));
}
[Fact]
public void UnsubscribeDebugView_IsTellNotAsk()
{
// Verify the method signature is void (fire-and-forget Tell pattern)
var method = typeof(CommunicationService).GetMethod("UnsubscribeDebugView");
Assert.NotNull(method);
Assert.Equal(typeof(void), method!.ReturnType);
}
}

View File

@@ -0,0 +1,102 @@
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Messages.RemoteQuery;
namespace ScadaLink.Communication.Tests;
/// <summary>
/// WP-1: Tests that message contracts have correlation IDs and proper structure.
/// </summary>
public class MessageContractTests
{
[Fact]
public void IntegrationCallRequest_HasCorrelationId()
{
var msg = new IntegrationCallRequest(
"corr-123", "site1", "inst1", "ExtSys1", "GetData",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
Assert.Equal("corr-123", msg.CorrelationId);
}
[Fact]
public void IntegrationCallResponse_HasCorrelationId()
{
var msg = new IntegrationCallResponse(
"corr-123", "site1", true, "{}", null, DateTimeOffset.UtcNow);
Assert.Equal("corr-123", msg.CorrelationId);
}
[Fact]
public void EventLogQueryRequest_HasCorrelationId()
{
var msg = new EventLogQueryRequest(
"corr-456", "site1", null, null, null, null, null, null, null, 25, DateTimeOffset.UtcNow);
Assert.Equal("corr-456", msg.CorrelationId);
}
[Fact]
public void EventLogQueryResponse_HasCorrelationId()
{
var msg = new EventLogQueryResponse(
"corr-456", "site1", [], null, false, true, null, DateTimeOffset.UtcNow);
Assert.Equal("corr-456", msg.CorrelationId);
}
[Fact]
public void ParkedMessageQueryRequest_HasCorrelationId()
{
var msg = new ParkedMessageQueryRequest(
"corr-789", "site1", 1, 25, DateTimeOffset.UtcNow);
Assert.Equal("corr-789", msg.CorrelationId);
}
[Fact]
public void ParkedMessageQueryResponse_HasCorrelationId()
{
var msg = new ParkedMessageQueryResponse(
"corr-789", "site1", [], 0, 1, 25, true, null, DateTimeOffset.UtcNow);
Assert.Equal("corr-789", msg.CorrelationId);
}
[Fact]
public void AllMessagePatterns_ExistAsRecordTypes()
{
// Verify all 8 patterns have proper request/response types
// Pattern 1: Deployment
Assert.True(typeof(Commons.Messages.Deployment.DeployInstanceCommand).IsValueType == false);
Assert.True(typeof(Commons.Messages.Deployment.DeploymentStatusResponse).IsValueType == false);
// Pattern 2: Lifecycle
Assert.True(typeof(Commons.Messages.Lifecycle.DisableInstanceCommand).IsValueType == false);
Assert.True(typeof(Commons.Messages.Lifecycle.InstanceLifecycleResponse).IsValueType == false);
// Pattern 3: Artifacts
Assert.True(typeof(Commons.Messages.Artifacts.DeployArtifactsCommand).IsValueType == false);
Assert.True(typeof(Commons.Messages.Artifacts.ArtifactDeploymentResponse).IsValueType == false);
// Pattern 4: Integration
Assert.True(typeof(IntegrationCallRequest).IsValueType == false);
Assert.True(typeof(IntegrationCallResponse).IsValueType == false);
// Pattern 5: Debug View
Assert.True(typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest).IsValueType == false);
Assert.True(typeof(Commons.Messages.DebugView.DebugViewSnapshot).IsValueType == false);
// Pattern 6: Health
Assert.True(typeof(Commons.Messages.Health.SiteHealthReport).IsValueType == false);
// Pattern 7: Remote Queries
Assert.True(typeof(EventLogQueryRequest).IsValueType == false);
Assert.True(typeof(EventLogQueryResponse).IsValueType == false);
Assert.True(typeof(ParkedMessageQueryRequest).IsValueType == false);
Assert.True(typeof(ParkedMessageQueryResponse).IsValueType == false);
// Pattern 8: Heartbeat
Assert.True(typeof(Commons.Messages.Health.HeartbeatMessage).IsValueType == false);
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -9,8 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -21,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.Communication/ScadaLink.Communication.csproj" />
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,104 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Messages.RemoteQuery;
using ScadaLink.Communication.Actors;
namespace ScadaLink.Communication.Tests;
/// <summary>
/// WP-4: Tests for SiteCommunicationActor message routing to local actors.
/// </summary>
public class SiteCommunicationActorTests : TestKit
{
private readonly CommunicationOptions _options = new();
public SiteCommunicationActorTests()
: base(@"akka.loglevel = DEBUG")
{
}
[Fact]
public void DeployCommand_ForwardedToDeploymentManager()
{
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
var command = new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
siteActor.Tell(command);
dmProbe.ExpectMsg<DeployInstanceCommand>(msg => msg.DeploymentId == "dep1");
}
[Fact]
public void LifecycleCommands_ForwardedToDeploymentManager()
{
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
siteActor.Tell(new DisableInstanceCommand("cmd1", "inst1", DateTimeOffset.UtcNow));
dmProbe.ExpectMsg<DisableInstanceCommand>();
siteActor.Tell(new EnableInstanceCommand("cmd2", "inst1", DateTimeOffset.UtcNow));
dmProbe.ExpectMsg<EnableInstanceCommand>();
siteActor.Tell(new DeleteInstanceCommand("cmd3", "inst1", DateTimeOffset.UtcNow));
dmProbe.ExpectMsg<DeleteInstanceCommand>();
}
[Fact]
public void IntegrationCall_WithoutHandler_ReturnsFailure()
{
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
var request = new IntegrationCallRequest(
"corr1", "site1", "inst1", "ExtSys1", "GetData",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
siteActor.Tell(request);
ExpectMsg<IntegrationCallResponse>(msg =>
!msg.Success && msg.ErrorMessage == "Integration handler not available");
}
[Fact]
public void IntegrationCall_WithHandler_ForwardedToHandler()
{
var dmProbe = CreateTestProbe();
var handlerProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
// Register integration handler
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.Integration, handlerProbe.Ref));
var request = new IntegrationCallRequest(
"corr1", "site1", "inst1", "ExtSys1", "GetData",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
siteActor.Tell(request);
handlerProbe.ExpectMsg<IntegrationCallRequest>(msg => msg.CorrelationId == "corr1");
}
[Fact]
public void EventLogQuery_WithoutHandler_ReturnsFailure()
{
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
var request = new EventLogQueryRequest(
"corr1", "site1", null, null, null, null, null, null, null, 25, DateTimeOffset.UtcNow);
siteActor.Tell(request);
ExpectMsg<EventLogQueryResponse>(msg => !msg.Success);
}
}

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.Communication.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,144 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Messages.DataConnection;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Actors;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-6: Tests for DataConnectionActor Become/Stash state machine.
/// WP-9: Auto-reconnect and bad quality tests.
/// WP-10: Transparent re-subscribe tests.
/// WP-11: Write-back support tests.
/// WP-12: Tag path resolution with retry tests.
/// WP-13: Health reporting tests.
/// WP-14: Subscription lifecycle tests.
/// </summary>
public class DataConnectionActorTests : TestKit
{
private readonly IDataConnection _mockAdapter;
private readonly DataConnectionOptions _options;
public DataConnectionActorTests()
: base(@"akka.loglevel = DEBUG")
{
_mockAdapter = Substitute.For<IDataConnection>();
_options = new DataConnectionOptions
{
ReconnectInterval = TimeSpan.FromMilliseconds(100),
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200),
WriteTimeout = TimeSpan.FromSeconds(5)
};
}
private IActorRef CreateConnectionActor(string name = "test-conn")
{
return Sys.ActorOf(Props.Create(() =>
new DataConnectionActor(name, _mockAdapter, _options)), name);
}
[Fact]
public void WP6_StartsInConnectingState_AttemptsConnect()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = CreateConnectionActor();
// Give it time to attempt connection
AwaitCondition(() =>
_mockAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(2));
}
[Fact]
public void WP6_ConnectingState_StashesSubscribeRequests()
{
// Make connect hang so we stay in Connecting
var tcs = new TaskCompletionSource();
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(tcs.Task);
var actor = CreateConnectionActor("stash-test");
// Send subscribe while connecting — should be stashed
actor.Tell(new SubscribeTagsRequest(
"corr1", "inst1", "stash-test", ["tag1"], DateTimeOffset.UtcNow));
// No response yet (stashed)
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
// Complete connection — should unstash and process
_mockAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns("sub-001");
tcs.SetResult();
// Now we should get the response
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(2));
}
[Fact]
public async Task WP11_ConnectedState_Write_ReturnsResult()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.WriteAsync("tag1", 42, Arg.Any<CancellationToken>())
.Returns(new WriteResult(true, null));
var actor = CreateConnectionActor("write-test");
// Wait for connected state
AwaitCondition(() =>
_mockAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(2));
// Small delay for state transition
await Task.Delay(200);
actor.Tell(new WriteTagRequest("corr1", "write-test", "tag1", 42, DateTimeOffset.UtcNow));
var response = ExpectMsg<WriteTagResponse>(TimeSpan.FromSeconds(3));
Assert.True(response.Success);
}
[Fact]
public async Task WP11_Write_Failure_ReturnedSynchronously()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.WriteAsync("tag1", 42, Arg.Any<CancellationToken>())
.Returns(new WriteResult(false, "Device offline"));
var actor = CreateConnectionActor("write-fail-test");
await Task.Delay(300);
actor.Tell(new WriteTagRequest("corr1", "write-fail-test", "tag1", 42, DateTimeOffset.UtcNow));
var response = ExpectMsg<WriteTagResponse>(TimeSpan.FromSeconds(3));
Assert.False(response.Success);
Assert.Equal("Device offline", response.ErrorMessage);
}
[Fact]
public async Task WP13_HealthReport_ReturnsConnectionStatus()
{
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.Status.Returns(ConnectionHealth.Connected);
var actor = CreateConnectionActor("health-test");
await Task.Delay(300);
actor.Tell(new DataConnectionActor.GetHealthReport());
var report = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(2));
Assert.Equal("health-test", report.ConnectionName);
Assert.Equal(ConnectionHealth.Connected, report.Status);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-34: Tests for protocol extensibility via DataConnectionFactory.
/// </summary>
public class DataConnectionFactoryTests
{
[Fact]
public void Create_OpcUa_ReturnsOpcUaAdapter()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
var connection = factory.Create("OpcUa", new Dictionary<string, string>());
Assert.IsType<OpcUaDataConnection>(connection);
}
[Fact]
public void Create_LmxProxy_ReturnsLmxProxyAdapter()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
var connection = factory.Create("LmxProxy", new Dictionary<string, string>());
Assert.IsType<LmxProxyDataConnection>(connection);
}
[Fact]
public void Create_CaseInsensitive()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
var connection = factory.Create("opcua", new Dictionary<string, string>());
Assert.IsType<OpcUaDataConnection>(connection);
}
[Fact]
public void Create_UnknownProtocol_Throws()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
var ex = Assert.Throws<ArgumentException>(() =>
factory.Create("UnknownProtocol", new Dictionary<string, string>()));
Assert.Contains("Unknown protocol type", ex.Message);
Assert.Contains("OpcUa", ex.Message);
}
[Fact]
public void RegisterAdapter_ExtendsFactory()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
// WP-34: Adding new protocol = register adapter
factory.RegisterAdapter("Custom", _ => new OpcUaDataConnection(
new DefaultOpcUaClientFactory(), NullLogger<OpcUaDataConnection>.Instance));
var connection = factory.Create("Custom", new Dictionary<string, string>());
Assert.NotNull(connection);
}
}

View File

@@ -0,0 +1,77 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Messages.DataConnection;
using ScadaLink.DataConnectionLayer.Actors;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-34: Tests for DataConnectionManagerActor routing and lifecycle.
/// </summary>
public class DataConnectionManagerActorTests : TestKit
{
private readonly IDataConnectionFactory _mockFactory;
private readonly DataConnectionOptions _options;
public DataConnectionManagerActorTests()
: base(@"akka.loglevel = DEBUG")
{
_mockFactory = Substitute.For<IDataConnectionFactory>();
_options = new DataConnectionOptions
{
ReconnectInterval = TimeSpan.FromMilliseconds(100),
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200)
};
}
[Fact]
public void WriteToUnknownConnection_ReturnsError()
{
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_mockFactory, _options)));
manager.Tell(new WriteTagRequest(
"corr1", "nonexistent", "tag1", 42, DateTimeOffset.UtcNow));
var response = ExpectMsg<WriteTagResponse>();
Assert.False(response.Success);
Assert.Contains("Unknown connection", response.ErrorMessage);
}
[Fact]
public void SubscribeToUnknownConnection_ReturnsError()
{
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_mockFactory, _options)));
manager.Tell(new SubscribeTagsRequest(
"corr1", "inst1", "nonexistent", ["tag1"], DateTimeOffset.UtcNow));
var response = ExpectMsg<SubscribeTagsResponse>();
Assert.False(response.Success);
Assert.Contains("Unknown connection", response.ErrorMessage);
}
[Fact]
public void CreateConnection_UsesFactory()
{
var mockAdapter = Substitute.For<IDataConnection>();
mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockFactory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
.Returns(mockAdapter);
var manager = Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_mockFactory, _options)));
manager.Tell(new CreateConnectionCommand(
"conn1", "OpcUa", new Dictionary<string, string>()));
// Factory should have been called
AwaitCondition(() =>
_mockFactory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2));
}
}

View File

@@ -0,0 +1,126 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-8: Tests for LmxProxy adapter.
/// </summary>
public class LmxProxyDataConnectionTests
{
private readonly ILmxProxyClient _mockClient;
private readonly ILmxProxyClientFactory _mockFactory;
private readonly LmxProxyDataConnection _adapter;
public LmxProxyDataConnectionTests()
{
_mockClient = Substitute.For<ILmxProxyClient>();
_mockFactory = Substitute.For<ILmxProxyClientFactory>();
_mockFactory.Create().Returns(_mockClient);
_adapter = new LmxProxyDataConnection(_mockFactory, NullLogger<LmxProxyDataConnection>.Instance);
}
[Fact]
public async Task Connect_OpensSessionWithHostAndPort()
{
_mockClient.OpenSessionAsync("myhost", 5001, Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>
{
["Host"] = "myhost",
["Port"] = "5001"
});
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
await _mockClient.Received(1).OpenSessionAsync("myhost", 5001, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Disconnect_ClosesSession()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
await _adapter.ConnectAsync(new Dictionary<string, string>());
await _adapter.DisconnectAsync();
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
await _mockClient.Received(1).CloseSessionAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Write_Success_ReturnsGoodResult()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.WriteTagAsync("Tag1", 42, Arg.Any<CancellationToken>())
.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.WriteAsync("Tag1", 42);
Assert.True(result.Success);
}
[Fact]
public async Task Write_Failure_ReturnsError()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.WriteTagAsync("Tag1", 42, Arg.Any<CancellationToken>())
.Returns(false);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.WriteAsync("Tag1", 42);
Assert.False(result.Success);
Assert.Equal("LmxProxy write failed", result.ErrorMessage);
}
[Fact]
public async Task Read_Good_ReturnsValue()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.ReadTagAsync("Tag1", Arg.Any<CancellationToken>())
.Returns((42.5, DateTime.UtcNow, true));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.ReadAsync("Tag1");
Assert.True(result.Success);
Assert.Equal(42.5, result.Value!.Value);
}
[Fact]
public async Task Read_Bad_ReturnsFailure()
{
_mockClient.IsConnected.Returns(true);
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("session-123");
_mockClient.ReadTagAsync("Tag1", Arg.Any<CancellationToken>())
.Returns((null, DateTime.UtcNow, false));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.ReadAsync("Tag1");
Assert.False(result.Success);
}
[Fact]
public async Task NotConnected_ThrowsOnOperations()
{
_mockClient.IsConnected.Returns(false);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_adapter.ReadAsync("tag1"));
}
}

View File

@@ -0,0 +1,152 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Adapters;
namespace ScadaLink.DataConnectionLayer.Tests;
/// <summary>
/// WP-7: Tests for OPC UA adapter.
/// </summary>
public class OpcUaDataConnectionTests
{
private readonly IOpcUaClient _mockClient;
private readonly IOpcUaClientFactory _mockFactory;
private readonly OpcUaDataConnection _adapter;
public OpcUaDataConnectionTests()
{
_mockClient = Substitute.For<IOpcUaClient>();
_mockFactory = Substitute.For<IOpcUaClientFactory>();
_mockFactory.Create().Returns(_mockClient);
_adapter = new OpcUaDataConnection(_mockFactory, NullLogger<OpcUaDataConnection>.Instance);
}
[Fact]
public async Task Connect_SetsStatusToConnected()
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>
{
["EndpointUrl"] = "opc.tcp://localhost:4840"
});
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<CancellationToken>());
}
[Fact]
public async Task Disconnect_SetsStatusToDisconnected()
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
await _adapter.DisconnectAsync();
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
}
[Fact]
public async Task Subscribe_DelegatesAndReturnsId()
{
_mockClient.IsConnected.Returns(true);
_mockClient.CreateSubscriptionAsync(Arg.Any<string>(), Arg.Any<Action<string, object?, DateTime, uint>>(), Arg.Any<CancellationToken>())
.Returns("sub-001");
await _adapter.ConnectAsync(new Dictionary<string, string>());
var subId = await _adapter.SubscribeAsync("ns=2;s=Tag1", (_, _) => { });
Assert.Equal("sub-001", subId);
}
[Fact]
public async Task Write_Success_ReturnsGoodResult()
{
_mockClient.IsConnected.Returns(true);
_mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any<CancellationToken>())
.Returns((uint)0);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42);
Assert.True(result.Success);
Assert.Null(result.ErrorMessage);
}
[Fact]
public async Task Write_Failure_ReturnsError()
{
_mockClient.IsConnected.Returns(true);
_mockClient.WriteValueAsync("ns=2;s=Tag1", 42, Arg.Any<CancellationToken>())
.Returns(0x80000000u);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.WriteAsync("ns=2;s=Tag1", 42);
Assert.False(result.Success);
Assert.Contains("0x80000000", result.ErrorMessage);
}
[Fact]
public async Task Read_BadStatus_ReturnsBadResult()
{
_mockClient.IsConnected.Returns(true);
_mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any<CancellationToken>())
.Returns((null, DateTime.UtcNow, 0x80000000u));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.ReadAsync("ns=2;s=Tag1");
Assert.False(result.Success);
}
[Fact]
public async Task Read_GoodStatus_ReturnsValue()
{
_mockClient.IsConnected.Returns(true);
_mockClient.ReadValueAsync("ns=2;s=Tag1", Arg.Any<CancellationToken>())
.Returns((42.5, DateTime.UtcNow, 0u));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var result = await _adapter.ReadAsync("ns=2;s=Tag1");
Assert.True(result.Success);
Assert.NotNull(result.Value);
Assert.Equal(42.5, result.Value!.Value);
Assert.Equal(QualityCode.Good, result.Value.Quality);
}
[Fact]
public async Task ReadBatch_ReadsAllTags()
{
_mockClient.IsConnected.Returns(true);
_mockClient.ReadValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((1.0, DateTime.UtcNow, 0u));
await _adapter.ConnectAsync(new Dictionary<string, string>());
var results = await _adapter.ReadBatchAsync(["tag1", "tag2", "tag3"]);
Assert.Equal(3, results.Count);
Assert.All(results.Values, r => Assert.True(r.Success));
}
[Fact]
public async Task NotConnected_ThrowsOnOperations()
{
_mockClient.IsConnected.Returns(false);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_adapter.ReadAsync("tag1"));
}
[Fact]
public async Task DisposeAsync_CleansUp()
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
await _adapter.DisposeAsync();
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -9,8 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -21,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj" />
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.DataConnectionLayer.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,180 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.HealthMonitoring.Tests;
/// <summary>
/// A simple fake TimeProvider for testing that allows advancing time manually.
/// </summary>
internal sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public TestTimeProvider(DateTimeOffset startTime)
{
_utcNow = startTime;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow += duration;
}
public class CentralHealthAggregatorTests
{
private readonly TestTimeProvider _timeProvider;
private readonly CentralHealthAggregator _aggregator;
public CentralHealthAggregatorTests()
{
_timeProvider = new TestTimeProvider(DateTimeOffset.UtcNow);
var options = Options.Create(new HealthMonitoringOptions
{
OfflineTimeout = TimeSpan.FromSeconds(60)
});
_aggregator = new CentralHealthAggregator(
options,
NullLogger<CentralHealthAggregator>.Instance,
_timeProvider);
}
private static SiteHealthReport MakeReport(string siteId, long seq) =>
new(
SiteId: siteId,
SequenceNumber: seq,
ReportTimestamp: DateTimeOffset.UtcNow,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
ScriptErrorCount: 0,
AlarmEvaluationErrorCount: 0,
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
DeadLetterCount: 0);
[Fact]
public void ProcessReport_StoresState_ForNewSite()
{
_aggregator.ProcessReport(MakeReport("site-1", 1));
var state = _aggregator.GetSiteState("site-1");
Assert.NotNull(state);
Assert.True(state.IsOnline);
Assert.Equal(1, state.LastSequenceNumber);
}
[Fact]
public void ProcessReport_UpdatesState_WhenSequenceIncreases()
{
_aggregator.ProcessReport(MakeReport("site-1", 1));
_aggregator.ProcessReport(MakeReport("site-1", 2));
var state = _aggregator.GetSiteState("site-1");
Assert.Equal(2, state!.LastSequenceNumber);
}
[Fact]
public void ProcessReport_RejectsStaleReport_WhenSequenceNotGreater()
{
_aggregator.ProcessReport(MakeReport("site-1", 5));
_aggregator.ProcessReport(MakeReport("site-1", 3));
var state = _aggregator.GetSiteState("site-1");
Assert.Equal(5, state!.LastSequenceNumber);
}
[Fact]
public void ProcessReport_RejectsEqualSequence()
{
_aggregator.ProcessReport(MakeReport("site-1", 5));
_aggregator.ProcessReport(MakeReport("site-1", 5));
var state = _aggregator.GetSiteState("site-1");
Assert.Equal(5, state!.LastSequenceNumber);
}
[Fact]
public void OfflineDetection_SiteGoesOffline_WhenNoReportWithinTimeout()
{
_aggregator.ProcessReport(MakeReport("site-1", 1));
Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline);
// Advance past the offline timeout
_timeProvider.Advance(TimeSpan.FromSeconds(61));
_aggregator.CheckForOfflineSites();
Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline);
}
[Fact]
public void OnlineRecovery_SiteComesBackOnline_WhenReportReceived()
{
_aggregator.ProcessReport(MakeReport("site-1", 1));
// Go offline
_timeProvider.Advance(TimeSpan.FromSeconds(61));
_aggregator.CheckForOfflineSites();
Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline);
// Receive new report → back online
_aggregator.ProcessReport(MakeReport("site-1", 2));
Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline);
}
[Fact]
public void OfflineDetection_SiteRemainsOnline_WhenReportWithinTimeout()
{
_aggregator.ProcessReport(MakeReport("site-1", 1));
_timeProvider.Advance(TimeSpan.FromSeconds(30));
_aggregator.CheckForOfflineSites();
Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline);
}
[Fact]
public void GetAllSiteStates_ReturnsAllKnownSites()
{
_aggregator.ProcessReport(MakeReport("site-1", 1));
_aggregator.ProcessReport(MakeReport("site-2", 1));
var states = _aggregator.GetAllSiteStates();
Assert.Equal(2, states.Count);
Assert.Contains("site-1", states.Keys);
Assert.Contains("site-2", states.Keys);
}
[Fact]
public void GetSiteState_ReturnsNull_ForUnknownSite()
{
var state = _aggregator.GetSiteState("nonexistent");
Assert.Null(state);
}
[Fact]
public void ProcessReport_StoresLatestReport()
{
var report = MakeReport("site-1", 1) with { ScriptErrorCount = 42 };
_aggregator.ProcessReport(report);
var state = _aggregator.GetSiteState("site-1");
Assert.Equal(42, state!.LatestReport.ScriptErrorCount);
}
[Fact]
public void SequenceNumberReset_RejectedUntilExceedsPrevMax()
{
// Site sends seq 10, then restarts and sends seq 1.
// Per design: sequence resets on singleton restart.
// The aggregator will reject seq 1 < 10 — expected behavior.
_aggregator.ProcessReport(MakeReport("site-1", 10));
_aggregator.ProcessReport(MakeReport("site-1", 1));
var state = _aggregator.GetSiteState("site-1");
Assert.Equal(10, state!.LastSequenceNumber);
// Once it exceeds the old max, it works again
_aggregator.ProcessReport(MakeReport("site-1", 11));
Assert.Equal(11, state.LastSequenceNumber);
}
}

View File

@@ -0,0 +1,141 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.HealthMonitoring.Tests;
public class HealthReportSenderTests
{
private class FakeTransport : IHealthReportTransport
{
public List<SiteHealthReport> SentReports { get; } = [];
public void Send(SiteHealthReport report) => SentReports.Add(report);
}
private class FakeSiteIdentityProvider : ISiteIdentityProvider
{
public string SiteId { get; set; } = "test-site";
}
[Fact]
public async Task SendsReportsWithMonotonicSequenceNumbers()
{
var transport = new FakeTransport();
var collector = new SiteHealthCollector();
var options = Options.Create(new HealthMonitoringOptions
{
ReportInterval = TimeSpan.FromMilliseconds(50)
});
var sender = new HealthReportSender(
collector,
transport,
options,
NullLogger<HealthReportSender>.Instance,
new FakeSiteIdentityProvider { SiteId = "site-A" });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
try
{
await sender.StartAsync(cts.Token);
await Task.Delay(280, CancellationToken.None);
await sender.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
// Should have sent several reports
Assert.True(transport.SentReports.Count >= 2,
$"Expected at least 2 reports, got {transport.SentReports.Count}");
// Verify monotonic sequence numbers starting at 1
for (int i = 0; i < transport.SentReports.Count; i++)
{
Assert.Equal(i + 1, transport.SentReports[i].SequenceNumber);
Assert.Equal("site-A", transport.SentReports[i].SiteId);
}
}
[Fact]
public async Task SequenceNumberStartsAtOne()
{
var transport = new FakeTransport();
var collector = new SiteHealthCollector();
var options = Options.Create(new HealthMonitoringOptions
{
ReportInterval = TimeSpan.FromMilliseconds(50)
});
var sender = new HealthReportSender(
collector,
transport,
options,
NullLogger<HealthReportSender>.Instance,
new FakeSiteIdentityProvider());
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150));
try
{
await sender.StartAsync(cts.Token);
await Task.Delay(120, CancellationToken.None);
await sender.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
Assert.True(transport.SentReports.Count >= 1);
Assert.Equal(1, transport.SentReports[0].SequenceNumber);
}
[Fact]
public async Task ReportsIncludeUtcTimestamp()
{
var transport = new FakeTransport();
var collector = new SiteHealthCollector();
var options = Options.Create(new HealthMonitoringOptions
{
ReportInterval = TimeSpan.FromMilliseconds(50)
});
var sender = new HealthReportSender(
collector,
transport,
options,
NullLogger<HealthReportSender>.Instance,
new FakeSiteIdentityProvider());
var before = DateTimeOffset.UtcNow;
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150));
try
{
await sender.StartAsync(cts.Token);
await Task.Delay(120, CancellationToken.None);
await sender.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
var after = DateTimeOffset.UtcNow;
Assert.True(transport.SentReports.Count >= 1);
foreach (var report in transport.SentReports)
{
Assert.InRange(report.ReportTimestamp, before, after);
Assert.Equal(TimeSpan.Zero, report.ReportTimestamp.Offset);
}
}
[Fact]
public void InitialSequenceNumberIsZero()
{
var transport = new FakeTransport();
var collector = new SiteHealthCollector();
var options = Options.Create(new HealthMonitoringOptions());
var sender = new HealthReportSender(
collector,
transport,
options,
NullLogger<HealthReportSender>.Instance,
new FakeSiteIdentityProvider());
Assert.Equal(0, sender.CurrentSequenceNumber);
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -10,6 +10,8 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -23,4 +25,4 @@
<ProjectReference Include="../../src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,159 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.HealthMonitoring.Tests;
public class SiteHealthCollectorTests
{
private readonly SiteHealthCollector _collector = new();
[Fact]
public void CollectReport_ReturnsZeroCounters_WhenNoErrorsRecorded()
{
var report = _collector.CollectReport("site-1");
Assert.Equal("site-1", report.SiteId);
Assert.Equal(0, report.ScriptErrorCount);
Assert.Equal(0, report.AlarmEvaluationErrorCount);
Assert.Equal(0, report.DeadLetterCount);
}
[Fact]
public void IncrementScriptError_AccumulatesBetweenReports()
{
_collector.IncrementScriptError();
_collector.IncrementScriptError();
_collector.IncrementScriptError();
var report = _collector.CollectReport("site-1");
Assert.Equal(3, report.ScriptErrorCount);
}
[Fact]
public void IncrementAlarmError_AccumulatesBetweenReports()
{
_collector.IncrementAlarmError();
_collector.IncrementAlarmError();
var report = _collector.CollectReport("site-1");
Assert.Equal(2, report.AlarmEvaluationErrorCount);
}
[Fact]
public void IncrementDeadLetter_AccumulatesBetweenReports()
{
_collector.IncrementDeadLetter();
var report = _collector.CollectReport("site-1");
Assert.Equal(1, report.DeadLetterCount);
}
[Fact]
public void CollectReport_ResetsCounters_AfterCollection()
{
_collector.IncrementScriptError();
_collector.IncrementAlarmError();
_collector.IncrementDeadLetter();
var first = _collector.CollectReport("site-1");
Assert.Equal(1, first.ScriptErrorCount);
Assert.Equal(1, first.AlarmEvaluationErrorCount);
Assert.Equal(1, first.DeadLetterCount);
var second = _collector.CollectReport("site-1");
Assert.Equal(0, second.ScriptErrorCount);
Assert.Equal(0, second.AlarmEvaluationErrorCount);
Assert.Equal(0, second.DeadLetterCount);
}
[Fact]
public void UpdateConnectionHealth_ReflectedInReport()
{
_collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected);
_collector.UpdateConnectionHealth("opc-2", ConnectionHealth.Disconnected);
var report = _collector.CollectReport("site-1");
Assert.Equal(2, report.DataConnectionStatuses.Count);
Assert.Equal(ConnectionHealth.Connected, report.DataConnectionStatuses["opc-1"]);
Assert.Equal(ConnectionHealth.Disconnected, report.DataConnectionStatuses["opc-2"]);
}
[Fact]
public void ConnectionHealth_NotResetAfterCollect()
{
_collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected);
_collector.CollectReport("site-1");
var second = _collector.CollectReport("site-1");
Assert.Single(second.DataConnectionStatuses);
Assert.Equal(ConnectionHealth.Connected, second.DataConnectionStatuses["opc-1"]);
}
[Fact]
public void RemoveConnection_RemovesFromReport()
{
_collector.UpdateConnectionHealth("opc-1", ConnectionHealth.Connected);
_collector.UpdateTagResolution("opc-1", 10, 8);
_collector.RemoveConnection("opc-1");
var report = _collector.CollectReport("site-1");
Assert.Empty(report.DataConnectionStatuses);
Assert.Empty(report.TagResolutionCounts);
}
[Fact]
public void UpdateTagResolution_ReflectedInReport()
{
_collector.UpdateTagResolution("opc-1", 50, 45);
var report = _collector.CollectReport("site-1");
Assert.Single(report.TagResolutionCounts);
Assert.Equal(50, report.TagResolutionCounts["opc-1"].TotalSubscribed);
Assert.Equal(45, report.TagResolutionCounts["opc-1"].SuccessfullyResolved);
}
[Fact]
public void StoreAndForwardBufferDepths_IsEmptyPlaceholder()
{
var report = _collector.CollectReport("site-1");
Assert.Empty(report.StoreAndForwardBufferDepths);
}
[Fact]
public void CollectReport_IncludesUtcTimestamp()
{
var before = DateTimeOffset.UtcNow;
var report = _collector.CollectReport("site-1");
var after = DateTimeOffset.UtcNow;
Assert.InRange(report.ReportTimestamp, before, after);
}
[Fact]
public void CollectReport_SequenceNumberIsZero_CallerAssignsIt()
{
var report = _collector.CollectReport("site-1");
Assert.Equal(0, report.SequenceNumber);
}
[Fact]
public async Task ThreadSafety_ConcurrentIncrements()
{
const int iterations = 10_000;
var tasks = new[]
{
Task.Run(() => { for (int i = 0; i < iterations; i++) _collector.IncrementScriptError(); }),
Task.Run(() => { for (int i = 0; i < iterations; i++) _collector.IncrementAlarmError(); }),
Task.Run(() => { for (int i = 0; i < iterations; i++) _collector.IncrementDeadLetter(); })
};
await Task.WhenAll(tasks);
var report = _collector.CollectReport("site-1");
Assert.Equal(iterations, report.ScriptErrorCount);
Assert.Equal(iterations, report.AlarmEvaluationErrorCount);
Assert.Equal(iterations, report.DeadLetterCount);
}
}

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.HealthMonitoring.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,119 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace ScadaLink.SiteEventLogging.Tests;
public class EventLogPurgeServiceTests : IDisposable
{
private readonly SiteEventLogger _eventLogger;
private readonly string _dbPath;
private readonly SiteEventLogOptions _options;
public EventLogPurgeServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db");
_options = new SiteEventLogOptions
{
DatabasePath = _dbPath,
RetentionDays = 30,
MaxStorageMb = 1024
};
_eventLogger = new SiteEventLogger(
Options.Create(_options),
NullLogger<SiteEventLogger>.Instance);
}
public void Dispose()
{
_eventLogger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
private EventLogPurgeService CreatePurgeService(SiteEventLogOptions? optionsOverride = null)
{
var opts = optionsOverride ?? _options;
return new EventLogPurgeService(
_eventLogger,
Options.Create(opts),
NullLogger<EventLogPurgeService>.Instance);
}
private void InsertEventWithTimestamp(DateTimeOffset timestamp)
{
using var cmd = _eventLogger.Connection.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (timestamp, event_type, severity, source, message)
VALUES ($ts, 'script', 'Info', 'Test', 'Test message')
""";
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
cmd.ExecuteNonQuery();
}
private long GetEventCount()
{
using var cmd = _eventLogger.Connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
return (long)cmd.ExecuteScalar()!;
}
[Fact]
public void PurgeByRetention_DeletesOldEvents()
{
// Insert an old event (31 days ago) and a recent one
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
var purge = CreatePurgeService();
purge.RunPurge();
Assert.Equal(1, GetEventCount());
}
[Fact]
public void PurgeByRetention_KeepsRecentEvents()
{
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-29));
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-1));
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
var purge = CreatePurgeService();
purge.RunPurge();
Assert.Equal(3, GetEventCount());
}
[Fact]
public void PurgeByStorageCap_DeletesOldestWhenOverCap()
{
// Insert enough events to have some data
for (int i = 0; i < 100; i++)
{
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
}
// Set an artificially small cap to trigger purge
var smallCapOptions = new SiteEventLogOptions
{
DatabasePath = _dbPath,
RetentionDays = 30,
MaxStorageMb = 0 // 0 MB cap forces purge
};
var purge = CreatePurgeService(smallCapOptions);
purge.RunPurge();
// All events should be purged since cap is 0
Assert.Equal(0, GetEventCount());
}
[Fact]
public void GetDatabaseSizeBytes_ReturnsPositiveValue()
{
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
var purge = CreatePurgeService();
var size = purge.GetDatabaseSizeBytes();
Assert.True(size > 0);
}
}

View File

@@ -0,0 +1,272 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.RemoteQuery;
namespace ScadaLink.SiteEventLogging.Tests;
public class EventLogQueryServiceTests : IDisposable
{
private readonly SiteEventLogger _eventLogger;
private readonly EventLogQueryService _queryService;
private readonly string _dbPath;
public EventLogQueryServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions
{
DatabasePath = _dbPath,
QueryPageSize = 500
});
_eventLogger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
_queryService = new EventLogQueryService(
_eventLogger,
options,
NullLogger<EventLogQueryService>.Instance);
}
public void Dispose()
{
_eventLogger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
private async Task SeedEvents()
{
await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script timeout");
await _eventLogger.LogEventAsync("alarm", "Warning", "inst-1", "AlarmActor:TempHigh", "Alarm triggered");
await _eventLogger.LogEventAsync("deployment", "Info", "inst-2", "DeploymentManager", "Instance deployed");
await _eventLogger.LogEventAsync("connection", "Error", null, "DCL:OPC1", "Connection lost");
await _eventLogger.LogEventAsync("script", "Info", "inst-2", "ScriptActor:Calculate", "Script completed");
}
private EventLogQueryRequest MakeRequest(
string? eventType = null,
string? severity = null,
string? instanceId = null,
string? keyword = null,
long? continuationToken = null,
int pageSize = 500,
DateTimeOffset? from = null,
DateTimeOffset? to = null) =>
new(
CorrelationId: Guid.NewGuid().ToString(),
SiteId: "site-1",
From: from,
To: to,
EventType: eventType,
Severity: severity,
InstanceId: instanceId,
KeywordFilter: keyword,
ContinuationToken: continuationToken,
PageSize: pageSize,
Timestamp: DateTimeOffset.UtcNow);
[Fact]
public async Task Query_ReturnsAllEvents_WhenNoFilters()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest());
Assert.True(response.Success);
Assert.Equal(5, response.Entries.Count);
Assert.False(response.HasMore);
}
[Fact]
public async Task Query_FiltersByEventType()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(eventType: "script"));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.All(response.Entries, e => Assert.Equal("script", e.EventType));
}
[Fact]
public async Task Query_FiltersBySeverity()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(severity: "Error"));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity));
}
[Fact]
public async Task Query_FiltersByInstanceId()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(instanceId: "inst-1"));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId));
}
[Fact]
public async Task Query_KeywordSearch_MatchesMessage()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Query_KeywordSearch_MatchesSource()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "AlarmActor"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Contains("AlarmActor", response.Entries[0].Source);
}
[Fact]
public async Task Query_CombinesMultipleFilters()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(
eventType: "script",
severity: "Error",
instanceId: "inst-1"));
Assert.True(response.Success);
Assert.Single(response.Entries);
Assert.Equal("Script timeout", response.Entries[0].Message);
}
[Fact]
public async Task Query_Pagination_ReturnsCorrectPageSize()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(pageSize: 2));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
Assert.True(response.HasMore);
Assert.NotNull(response.ContinuationToken);
}
[Fact]
public async Task Query_Pagination_ContinuationTokenWorksCorrectly()
{
await SeedEvents();
// Get first page
var page1 = _queryService.ExecuteQuery(MakeRequest(pageSize: 2));
Assert.Equal(2, page1.Entries.Count);
Assert.True(page1.HasMore);
// Get second page using continuation token
var page2 = _queryService.ExecuteQuery(MakeRequest(
pageSize: 2,
continuationToken: page1.ContinuationToken));
Assert.Equal(2, page2.Entries.Count);
Assert.True(page2.HasMore);
// Get third page
var page3 = _queryService.ExecuteQuery(MakeRequest(
pageSize: 2,
continuationToken: page2.ContinuationToken));
Assert.Single(page3.Entries);
Assert.False(page3.HasMore);
// Verify no overlapping entries
var allIds = page1.Entries.Select(e => e.Id)
.Concat(page2.Entries.Select(e => e.Id))
.Concat(page3.Entries.Select(e => e.Id))
.ToList();
Assert.Equal(5, allIds.Distinct().Count());
}
[Fact]
public async Task Query_FiltersByTimeRange()
{
// Insert events at controlled times
var now = DateTimeOffset.UtcNow;
// Insert with a direct SQL to control timestamps
InsertEventAt(now.AddHours(-2), "script", "Info", null, "S1", "Old event");
InsertEventAt(now.AddMinutes(-30), "script", "Info", null, "S2", "Recent event");
InsertEventAt(now, "script", "Info", null, "S3", "Now event");
var response = _queryService.ExecuteQuery(MakeRequest(
from: now.AddHours(-1),
to: now.AddMinutes(1)));
Assert.True(response.Success);
Assert.Equal(2, response.Entries.Count);
}
[Fact]
public async Task Query_EmptyResult_WhenNoMatches()
{
await SeedEvents();
var response = _queryService.ExecuteQuery(MakeRequest(eventType: "nonexistent"));
Assert.True(response.Success);
Assert.Empty(response.Entries);
Assert.False(response.HasMore);
Assert.Null(response.ContinuationToken);
}
[Fact]
public void Query_ReturnsCorrelationId()
{
var request = MakeRequest();
var response = _queryService.ExecuteQuery(request);
Assert.Equal(request.CorrelationId, response.CorrelationId);
Assert.Equal("site-1", response.SiteId);
}
[Fact]
public async Task Query_ReturnsAllEventLogEntryFields()
{
await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Run", "Failure", "{\"stack\":\"trace\"}");
var response = _queryService.ExecuteQuery(MakeRequest());
Assert.Single(response.Entries);
var entry = response.Entries[0];
Assert.True(entry.Id > 0);
Assert.Equal("script", entry.EventType);
Assert.Equal("Error", entry.Severity);
Assert.Equal("inst-1", entry.InstanceId);
Assert.Equal("ScriptActor:Run", entry.Source);
Assert.Equal("Failure", entry.Message);
Assert.Equal("{\"stack\":\"trace\"}", entry.Details);
}
private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message)
{
using var cmd = _eventLogger.Connection.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (timestamp, event_type, severity, instance_id, source, message)
VALUES ($ts, $et, $sev, $iid, $src, $msg)
""";
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
cmd.Parameters.AddWithValue("$et", eventType);
cmd.Parameters.AddWithValue("$sev", severity);
cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$src", source);
cmd.Parameters.AddWithValue("$msg", message);
cmd.ExecuteNonQuery();
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -10,6 +10,9 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -23,4 +26,4 @@
<ProjectReference Include="../../src/ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,143 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace ScadaLink.SiteEventLogging.Tests;
public class SiteEventLoggerTests : IDisposable
{
private readonly SiteEventLogger _logger;
private readonly SqliteConnection _verifyConnection;
private readonly string _dbPath;
public SiteEventLoggerTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
// Separate connection for verification queries
_verifyConnection = new SqliteConnection($"Data Source={_dbPath}");
_verifyConnection.Open();
}
public void Dispose()
{
_verifyConnection.Dispose();
_logger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
[Fact]
public async Task LogEventAsync_InsertsRecord()
{
await _logger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script failed", "{\"stack\":\"...\"}");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
var count = (long)cmd.ExecuteScalar()!;
Assert.Equal(1, count);
}
[Fact]
public async Task LogEventAsync_StoresAllFields()
{
await _logger.LogEventAsync("alarm", "Warning", "inst-2", "AlarmActor:TempHigh", "Alarm triggered", "{\"value\":95}");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT event_type, severity, instance_id, source, message, details FROM site_events LIMIT 1";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.Equal("alarm", reader.GetString(0));
Assert.Equal("Warning", reader.GetString(1));
Assert.Equal("inst-2", reader.GetString(2));
Assert.Equal("AlarmActor:TempHigh", reader.GetString(3));
Assert.Equal("Alarm triggered", reader.GetString(4));
Assert.Equal("{\"value\":95}", reader.GetString(5));
}
[Fact]
public async Task LogEventAsync_NullableFieldsAllowed()
{
await _logger.LogEventAsync("deployment", "Info", null, "DeploymentManager", "Deployed instance");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT instance_id, details FROM site_events LIMIT 1";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.True(reader.IsDBNull(0));
Assert.True(reader.IsDBNull(1));
}
[Fact]
public async Task LogEventAsync_StoresIso8601UtcTimestamp()
{
await _logger.LogEventAsync("connection", "Info", null, "DCL", "Connected");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT timestamp FROM site_events LIMIT 1";
var ts = (string)cmd.ExecuteScalar()!;
var parsed = DateTimeOffset.Parse(ts);
Assert.Equal(TimeSpan.Zero, parsed.Offset);
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptyEventType()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("", "Info", null, "Source", "Message"));
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptySeverity()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("script", "", null, "Source", "Message"));
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptySource()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("script", "Info", null, "", "Message"));
}
[Fact]
public async Task LogEventAsync_ThrowsOnEmptyMessage()
{
await Assert.ThrowsAsync<ArgumentException>(() =>
_logger.LogEventAsync("script", "Info", null, "Source", ""));
}
[Fact]
public async Task LogEventAsync_MultipleEvents_AutoIncrementIds()
{
await _logger.LogEventAsync("script", "Info", null, "S1", "First");
await _logger.LogEventAsync("script", "Info", null, "S2", "Second");
await _logger.LogEventAsync("script", "Info", null, "S3", "Third");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT id FROM site_events ORDER BY id";
using var reader = cmd.ExecuteReader();
var ids = new List<long>();
while (reader.Read()) ids.Add(reader.GetInt64(0));
Assert.Equal(3, ids.Count);
Assert.True(ids[0] < ids[1] && ids[1] < ids[2]);
}
[Fact]
public async Task AllEventTypes_Accepted()
{
var types = new[] { "script", "alarm", "deployment", "connection", "store_and_forward", "instance_lifecycle" };
foreach (var t in types)
{
await _logger.LogEventAsync(t, "Info", null, "Test", $"Event type: {t}");
}
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT COUNT(DISTINCT event_type) FROM site_events";
var count = (long)cmd.ExecuteScalar()!;
Assert.Equal(6, count);
}
}

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.SiteEventLogging.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,260 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.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));
}
}

View File

@@ -8,6 +8,7 @@ using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Tests.Actors;
@@ -19,6 +20,8 @@ namespace ScadaLink.SiteRuntime.Tests.Actors;
public class DeploymentManagerActorTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerActorTests()
@@ -28,6 +31,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
$"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()
@@ -36,6 +43,18 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
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
@@ -56,14 +75,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
await _storage.StoreDeployedConfigAsync("Pump1", MakeConfigJson("Pump1"), "d1", "h1", true);
await _storage.StoreDeployedConfigAsync("Pump2", MakeConfigJson("Pump2"), "d2", "h2", true);
var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 };
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
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
// 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));
@@ -77,14 +95,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
await _storage.StoreDeployedConfigAsync("Active1", MakeConfigJson("Active1"), "d1", "h1", true);
await _storage.StoreDeployedConfigAsync("Disabled1", MakeConfigJson("Disabled1"), "d2", "h2", false);
var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 };
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
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)
// 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);
@@ -101,9 +118,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
}
// Use a small batch size to force multiple batches
var options = new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 };
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
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);
@@ -120,9 +136,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
[Fact]
public async Task DeploymentManager_Deploy_CreatesNewInstance()
{
var options = new SiteRuntimeOptions();
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
var actor = CreateDeploymentManager();
await Task.Delay(500); // Wait for empty startup
@@ -137,9 +151,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
[Fact]
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
{
var options = new SiteRuntimeOptions();
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
var actor = CreateDeploymentManager();
await Task.Delay(500);
@@ -150,7 +162,6 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
// Wait for the async deploy persistence (PipeTo) to complete
// The deploy handler replies immediately but persists asynchronously
await Task.Delay(1000);
// Disable
@@ -179,15 +190,9 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
[Fact]
public void DeploymentManager_SupervisionStrategy_ResumesOnException()
{
// Verify the supervision strategy by creating the actor and checking
// that it uses OneForOneStrategy
var options = new SiteRuntimeOptions();
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, options, NullLogger<DeploymentManagerActor>.Instance)));
var actor = CreateDeploymentManager();
// The actor exists and is responsive supervision is configured
// The actual Resume behavior is verified implicitly: if an Instance Actor
// throws during message handling, it resumes rather than restarting
// The actor exists and is responsive -- supervision is configured
actor.Tell(new DeployInstanceCommand(
"dep-sup", "SupervisedPump", "sha256:sup",
MakeConfigJson("SupervisedPump"), "admin", DateTimeOffset.UtcNow));

View File

@@ -0,0 +1,226 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json;
namespace ScadaLink.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));
}
// Wait for all to process
for (int i = 0; i < 50; i++)
{
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(10));
}
// 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_DebugViewSubscriber_ReceivesChanges()
{
var actor = CreateInstanceWithScripts("Pump1");
// Subscribe to debug view
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-2"));
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
// Now change an attribute
actor.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "200", "Good", DateTimeOffset.UtcNow));
// The subscriber should receive the change notification
var changed = ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(5));
Assert.Equal("Temperature", changed.AttributeName);
Assert.Equal("200", changed.Value?.ToString());
}
[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);
// Subscribe to debug view to observe alarm state changes
actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-alarm"));
ExpectMsg<DebugViewSnapshot>(TimeSpan.FromSeconds(5));
// Send value outside range to trigger alarm
actor.Tell(new AttributeValueChanged(
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
// Should receive the attribute change first (from debug subscription)
ExpectMsg<AttributeValueChanged>(TimeSpan.FromSeconds(5));
// Then the alarm state change (forwarded by Instance Actor)
var alarmMsg = ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
Assert.Equal("HighTemp", alarmMsg.AlarmName);
Assert.Equal(Commons.Types.Enums.AlarmState.Active, alarmMsg.State);
}
}

View File

@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Tests.Actors;
@@ -16,6 +17,9 @@ namespace ScadaLink.SiteRuntime.Tests.Actors;
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()
@@ -25,6 +29,24 @@ public class InstanceActorTests : TestKit, IDisposable
$"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()
@@ -46,11 +68,7 @@ public class InstanceActorTests : TestKit, IDisposable
]
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Pump1",
JsonSerializer.Serialize(config),
_storage,
NullLogger<InstanceActor>.Instance)));
var actor = CreateInstanceActor("Pump1", config);
// Query for an attribute that exists
actor.Tell(new GetAttributeRequest(
@@ -71,11 +89,7 @@ public class InstanceActorTests : TestKit, IDisposable
Attributes = []
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Pump1",
JsonSerializer.Serialize(config),
_storage,
NullLogger<InstanceActor>.Instance)));
var actor = CreateInstanceActor("Pump1", config);
actor.Tell(new GetAttributeRequest(
"corr-2", "Pump1", "NonExistent", DateTimeOffset.UtcNow));
@@ -97,13 +111,9 @@ public class InstanceActorTests : TestKit, IDisposable
]
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Pump1",
JsonSerializer.Serialize(config),
_storage,
NullLogger<InstanceActor>.Instance)));
var actor = CreateInstanceActor("Pump1", config);
// Set a static attribute response comes async via PipeTo
// Set a static attribute -- response comes async via PipeTo
actor.Tell(new SetStaticAttributeCommand(
"corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow));
@@ -131,11 +141,7 @@ public class InstanceActorTests : TestKit, IDisposable
]
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"PumpPersist1",
JsonSerializer.Serialize(config),
_storage,
NullLogger<InstanceActor>.Instance)));
var actor = CreateInstanceActor("PumpPersist1", config);
actor.Tell(new SetStaticAttributeCommand(
"corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow));
@@ -166,11 +172,7 @@ public class InstanceActorTests : TestKit, IDisposable
]
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"PumpOverride1",
JsonSerializer.Serialize(config),
_storage,
NullLogger<InstanceActor>.Instance)));
var actor = CreateInstanceActor("PumpOverride1", config);
// Wait for the async override loading to complete (PipeTo)
await Task.Delay(1000);
@@ -200,7 +202,7 @@ public class InstanceActorTests : TestKit, IDisposable
overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy");
Assert.Empty(overrides);
// Create actor with fresh config should NOT have the override
// Create actor with fresh config -- should NOT have the override
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpRedeploy",
@@ -210,11 +212,7 @@ public class InstanceActorTests : TestKit, IDisposable
]
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"PumpRedeploy",
JsonSerializer.Serialize(config),
_storage,
NullLogger<InstanceActor>.Instance)));
var actor = CreateInstanceActor("PumpRedeploy", config);
await Task.Delay(1000);

View File

@@ -0,0 +1,240 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.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
}
}

View File

@@ -82,7 +82,8 @@ public class NegativeTests
checkCmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table'";
var tableCount = (long)(await checkCmd.ExecuteScalarAsync())!;
// Only 2 tables: deployed_configurations and static_attribute_overrides
// 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);
}

View File

@@ -0,0 +1,156 @@
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.SiteRuntime.Persistence;
namespace ScadaLink.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
}
}

View File

@@ -9,9 +9,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.Streams.TestKit" Version="1.5.62" />
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

View File

@@ -0,0 +1,111 @@
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.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);
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.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!));
}
}

View File

@@ -0,0 +1,118 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Streaming;
namespace ScadaLink.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(
Sys, options, NullLogger<SiteStreamManager>.Instance);
_streamManager.Initialize();
}
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);
}
}