feat: complete gRPC streaming channel — site host, docker config, docs, integration tests

Switch site host to WebApplicationBuilder with Kestrel HTTP/2 gRPC server,
add GrpcPort/keepalive config, wire SiteStreamManager as ISiteStreamSubscriber,
expose gRPC ports in docker-compose, add site seed script, update all 10
requirement docs + CLAUDE.md + README.md for the new dual-transport architecture.
This commit is contained in:
Joseph Doherty
2026-03-21 12:38:33 -04:00
parent 3fe3c4161b
commit 416a03b782
34 changed files with 728 additions and 156 deletions

View File

@@ -24,7 +24,7 @@ public class SiteStreamGrpcServerTests : TestKit
private SiteStreamGrpcServer CreateServer(int maxStreams = 100)
{
return new SiteStreamGrpcServer(_subscriber, Sys, _logger, maxStreams);
return new SiteStreamGrpcServer(_subscriber, _logger, maxStreams);
}
private static InstanceStreamRequest MakeRequest(string correlationId = "corr-1", string instance = "Site1.Pump01")
@@ -55,7 +55,7 @@ public class SiteStreamGrpcServerTests : TestKit
public async Task RejectsWhenMaxStreamsReached()
{
var server = CreateServer(maxStreams: 1);
server.SetReady();
server.SetReady(Sys);
// Start one stream that blocks
var cts1 = new CancellationTokenSource();
@@ -86,7 +86,7 @@ public class SiteStreamGrpcServerTests : TestKit
public async Task CancelsDuplicateCorrelationId()
{
var server = CreateServer();
server.SetReady();
server.SetReady(Sys);
var cts1 = new CancellationTokenSource();
var context1 = CreateMockContext(cts1.Token);
@@ -121,7 +121,7 @@ public class SiteStreamGrpcServerTests : TestKit
public async Task CleansUpOnCancellation()
{
var server = CreateServer();
server.SetReady();
server.SetReady(Sys);
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
@@ -142,7 +142,7 @@ public class SiteStreamGrpcServerTests : TestKit
public async Task SubscribesAndRemovesFromStreamManager()
{
var server = CreateServer();
server.SetReady();
server.SetReady(Sys);
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
@@ -167,7 +167,7 @@ public class SiteStreamGrpcServerTests : TestKit
public async Task WritesEventsToResponseStream()
{
var server = CreateServer();
server.SetReady();
server.SetReady(Sys);
// Capture the relay actor so we can send it events
IActorRef? capturedActor = null;
@@ -212,7 +212,7 @@ public class SiteStreamGrpcServerTests : TestKit
{
var server = CreateServer();
// Initially not ready -- just verify the property works
server.SetReady();
server.SetReady(Sys);
// No assertion needed -- the other tests verify that SetReady enables streaming
Assert.Equal(0, server.ActiveStreamCount);
}

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
@@ -21,10 +22,12 @@ using ScadaLink.ManagementService;
using ScadaLink.NotificationService;
using ScadaLink.Security;
using ScadaLink.SiteEventLogging;
using ScadaLink.Communication.Grpc;
using ScadaLink.SiteRuntime;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Repositories;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming;
using ScadaLink.StoreAndForward;
using ScadaLink.TemplateEngine;
using ScadaLink.TemplateEngine.Flattening;
@@ -274,45 +277,45 @@ public class CentralCompositionRootTests : IDisposable
/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
/// matches the real Program.cs registration.
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
/// </summary>
public class SiteCompositionRootTests : IDisposable
{
private readonly IHost _host;
private readonly WebApplication _host;
private readonly string _tempDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db");
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
});
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
});
// gRPC server registration (mirrors Program.cs site section)
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
_host.Dispose();
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
@@ -333,6 +336,9 @@ public class SiteCompositionRootTests : IDisposable
new object[] { typeof(SiteStorageService) },
new object[] { typeof(ScriptCompilationService) },
new object[] { typeof(SharedScriptLibrary) },
new object[] { typeof(SiteStreamManager) },
new object[] { typeof(ISiteStreamSubscriber) },
new object[] { typeof(SiteStreamGrpcServer) },
new object[] { typeof(IDataConnectionFactory) },
new object[] { typeof(StoreAndForwardStorage) },
new object[] { typeof(StoreAndForwardService) },

View File

@@ -1,4 +1,6 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -65,70 +67,74 @@ public class HostStartupTests : IDisposable
[Fact]
public void SiteRole_StartsWithoutError()
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
});
var host = builder.Build();
_disposables.Add(host);
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Remove AkkaHostedService from running
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
var app = builder.Build();
_disposables.Add(app);
// Build succeeds = DI container is valid and all services resolve
Assert.NotNull(host);
Assert.NotNull(host.Services);
Assert.NotNull(app);
Assert.NotNull(app.Services);
}
[Fact]
public void SiteRole_DoesNotConfigureKestrel()
public void SiteRole_ConfiguresKestrelForGrpc()
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(0, listenOptions =>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
});
var host = builder.Build();
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Verify no Kestrel server or web host is registered.
// Host.CreateDefaultBuilder does not add Kestrel, so there should be no IServer.
// Remove AkkaHostedService from running
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
var app = builder.Build();
// Verify Kestrel IS configured (site now hosts gRPC via WebApplicationBuilder)
var serverType = Type.GetType(
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
if (serverType != null)
{
var server = host.Services.GetService(serverType);
Assert.Null(server);
var server = app.Services.GetService(serverType);
Assert.NotNull(server);
}
// Additionally verify no HTTP URLs are configured
var config = host.Services.GetRequiredService<IConfiguration>();
var urls = config["urls"] ?? config["ASPNETCORE_URLS"];
Assert.Null(urls);
host.Dispose();
(app as IDisposable)?.Dispose();
}
[Fact]

View File

@@ -0,0 +1,348 @@
using System.Threading.Channels;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Communication.Actors;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.IntegrationTests.Grpc;
/// <summary>
/// Integration tests for the gRPC streaming pipeline.
/// Tests the full in-process flow: subscribe request -> StreamRelayActor creation ->
/// domain events via Akka Tell -> Channel relay -> gRPC response stream writes.
///
/// These tests exercise the real SiteStreamGrpcServer, StreamRelayActor, and Channel
/// wiring together with a real Akka actor system, using only mocked gRPC transport
/// (IServerStreamWriter + ServerCallContext).
///
/// Full end-to-end gRPC-over-HTTP/2 tests are performed manually against the Docker
/// cluster (docker/deploy.sh + docker/seed-sites.sh + CLI debug-stream).
/// </summary>
public class GrpcStreamIntegrationTests : TestKit
{
/// <summary>
/// End-to-end pipeline test: subscribe -> relay actor receives domain events ->
/// events flow through Channel to gRPC response stream.
/// Validates attribute value changes arrive with correct protobuf mapping.
/// </summary>
[Fact]
public async Task Pipeline_AttributeValueChanged_FlowsToResponseStream()
{
// Arrange: capture the relay actor created by the gRPC server
IActorRef? relayActor = null;
var subscriber = Substitute.For<ISiteStreamSubscriber>();
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
.Returns(ci =>
{
relayActor = ci.Arg<IActorRef>();
return "sub-integ-1";
});
var server = new SiteStreamGrpcServer(
subscriber,
NullLogger<SiteStreamGrpcServer>.Instance);
server.SetReady(Sys);
var writtenEvents = new List<SiteStreamEvent>();
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
var request = new InstanceStreamRequest
{
CorrelationId = "integ-attr-1",
InstanceUniqueName = "SiteA.Pump01"
};
// Act: start the subscription stream
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
await WaitForConditionAsync(() => relayActor != null);
// Send domain events to the relay actor (simulating what SiteStreamManager does)
var ts1 = new DateTimeOffset(2026, 3, 21, 14, 0, 0, TimeSpan.Zero);
var ts2 = new DateTimeOffset(2026, 3, 21, 14, 0, 1, TimeSpan.Zero);
relayActor!.Tell(new AttributeValueChanged(
"SiteA.Pump01", "Modules.Flow", "CurrentGPM", 125.3, "Good", ts1));
relayActor.Tell(new AttributeValueChanged(
"SiteA.Pump01", "Modules.Pressure", "CurrentPSI", 48.7, "Uncertain", ts2));
await WaitForConditionAsync(() => writtenEvents.Count >= 2);
// Cleanup
cts.Cancel();
await streamTask;
// Assert: both events arrived in order with correct protobuf mapping
Assert.Equal(2, writtenEvents.Count);
var evt1 = writtenEvents[0];
Assert.Equal("integ-attr-1", evt1.CorrelationId);
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, evt1.EventCase);
Assert.Equal("SiteA.Pump01", evt1.AttributeChanged.InstanceUniqueName);
Assert.Equal("Modules.Flow", evt1.AttributeChanged.AttributePath);
Assert.Equal("CurrentGPM", evt1.AttributeChanged.AttributeName);
Assert.Equal("125.3", evt1.AttributeChanged.Value);
Assert.Equal(Quality.Good, evt1.AttributeChanged.Quality);
Assert.Equal(Timestamp.FromDateTimeOffset(ts1), evt1.AttributeChanged.Timestamp);
var evt2 = writtenEvents[1];
Assert.Equal("integ-attr-1", evt2.CorrelationId);
Assert.Equal("Modules.Pressure", evt2.AttributeChanged.AttributePath);
Assert.Equal(Quality.Uncertain, evt2.AttributeChanged.Quality);
}
/// <summary>
/// End-to-end pipeline test for alarm state changes.
/// </summary>
[Fact]
public async Task Pipeline_AlarmStateChanged_FlowsToResponseStream()
{
IActorRef? relayActor = null;
var subscriber = Substitute.For<ISiteStreamSubscriber>();
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
.Returns(ci =>
{
relayActor = ci.Arg<IActorRef>();
return "sub-integ-2";
});
var server = new SiteStreamGrpcServer(
subscriber,
NullLogger<SiteStreamGrpcServer>.Instance);
server.SetReady(Sys);
var writtenEvents = new List<SiteStreamEvent>();
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
var request = new InstanceStreamRequest
{
CorrelationId = "integ-alarm-1",
InstanceUniqueName = "SiteA.Pump01"
};
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
await WaitForConditionAsync(() => relayActor != null);
var ts = new DateTimeOffset(2026, 3, 21, 14, 5, 0, TimeSpan.Zero);
relayActor!.Tell(new AlarmStateChanged(
"SiteA.Pump01", "HighPressure",
Commons.Types.Enums.AlarmState.Active, 3, ts));
await WaitForConditionAsync(() => writtenEvents.Count >= 1);
cts.Cancel();
await streamTask;
Assert.Single(writtenEvents);
var evt = writtenEvents[0];
Assert.Equal("integ-alarm-1", evt.CorrelationId);
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, evt.EventCase);
Assert.Equal("SiteA.Pump01", evt.AlarmChanged.InstanceUniqueName);
Assert.Equal("HighPressure", evt.AlarmChanged.AlarmName);
Assert.Equal(AlarmStateEnum.AlarmStateActive, evt.AlarmChanged.State);
Assert.Equal(3, evt.AlarmChanged.Priority);
Assert.Equal(Timestamp.FromDateTimeOffset(ts), evt.AlarmChanged.Timestamp);
}
/// <summary>
/// Verifies that mixed event types (attribute + alarm) flow through the same stream.
/// </summary>
[Fact]
public async Task Pipeline_MixedEvents_FlowInOrder()
{
IActorRef? relayActor = null;
var subscriber = Substitute.For<ISiteStreamSubscriber>();
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
.Returns(ci =>
{
relayActor = ci.Arg<IActorRef>();
return "sub-integ-3";
});
var server = new SiteStreamGrpcServer(
subscriber,
NullLogger<SiteStreamGrpcServer>.Instance);
server.SetReady(Sys);
var writtenEvents = new List<SiteStreamEvent>();
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
var request = new InstanceStreamRequest
{
CorrelationId = "integ-mixed-1",
InstanceUniqueName = "SiteB.Motor01"
};
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
await WaitForConditionAsync(() => relayActor != null);
var ts = DateTimeOffset.UtcNow;
// Send interleaved attribute and alarm events
relayActor!.Tell(new AttributeValueChanged(
"SiteB.Motor01", "Speed", "RPM", 1750.0, "Good", ts));
relayActor.Tell(new AlarmStateChanged(
"SiteB.Motor01", "OverSpeed",
Commons.Types.Enums.AlarmState.Active, 4, ts));
relayActor.Tell(new AttributeValueChanged(
"SiteB.Motor01", "Temperature", "BearingTemp", 85.2, "Good", ts));
await WaitForConditionAsync(() => writtenEvents.Count >= 3);
cts.Cancel();
await streamTask;
Assert.Equal(3, writtenEvents.Count);
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, writtenEvents[0].EventCase);
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, writtenEvents[1].EventCase);
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, writtenEvents[2].EventCase);
Assert.All(writtenEvents, e => Assert.Equal("integ-mixed-1", e.CorrelationId));
}
/// <summary>
/// Verifies that when a stream is cancelled, the subscriber is cleaned up
/// and the active stream count returns to zero.
/// </summary>
[Fact]
public async Task Pipeline_Cancellation_CleansUpRelayActorAndSubscription()
{
var subscriber = Substitute.For<ISiteStreamSubscriber>();
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
.Returns("sub-integ-4");
var server = new SiteStreamGrpcServer(
subscriber,
NullLogger<SiteStreamGrpcServer>.Instance);
server.SetReady(Sys);
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
var cts = new CancellationTokenSource();
var context = CreateMockContext(cts.Token);
var request = new InstanceStreamRequest
{
CorrelationId = "integ-cleanup-1",
InstanceUniqueName = "SiteC.Valve01"
};
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
// Cancel the stream
cts.Cancel();
await streamTask;
// Verify cleanup
Assert.Equal(0, server.ActiveStreamCount);
subscriber.Received(1).Subscribe("SiteC.Valve01", Arg.Any<IActorRef>());
subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>());
}
/// <summary>
/// Verifies that a duplicate correlation ID cancels the first stream and
/// the second stream continues to receive events.
/// </summary>
[Fact]
public async Task Pipeline_DuplicateCorrelationId_ReplacesStream()
{
IActorRef? relayActor2 = null;
var callCount = 0;
var subscriber = Substitute.For<ISiteStreamSubscriber>();
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
.Returns(ci =>
{
callCount++;
if (callCount == 2)
relayActor2 = ci.Arg<IActorRef>();
return $"sub-dup-{callCount}";
});
var server = new SiteStreamGrpcServer(
subscriber,
NullLogger<SiteStreamGrpcServer>.Instance);
server.SetReady(Sys);
// First stream
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
var cts1 = new CancellationTokenSource();
var context1 = CreateMockContext(cts1.Token);
var stream1Task = Task.Run(() => server.SubscribeInstance(
new InstanceStreamRequest { CorrelationId = "integ-dup", InstanceUniqueName = "SiteA.Pump01" },
writer1, context1));
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
// Second stream with same correlation ID -- should cancel first
var writtenEvents2 = new List<SiteStreamEvent>();
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
writer2.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => writtenEvents2.Add(ci.Arg<SiteStreamEvent>()));
var cts2 = new CancellationTokenSource();
var context2 = CreateMockContext(cts2.Token);
var stream2Task = Task.Run(() => server.SubscribeInstance(
new InstanceStreamRequest { CorrelationId = "integ-dup", InstanceUniqueName = "SiteA.Pump01" },
writer2, context2));
// First stream should complete
await stream1Task;
await WaitForConditionAsync(() => relayActor2 != null);
// Send event to second relay
var ts = DateTimeOffset.UtcNow;
relayActor2!.Tell(new AttributeValueChanged(
"SiteA.Pump01", "Flow", "GPM", 100.0, "Good", ts));
await WaitForConditionAsync(() => writtenEvents2.Count >= 1);
cts2.Cancel();
await stream2Task;
Assert.Single(writtenEvents2);
Assert.Equal("integ-dup", writtenEvents2[0].CorrelationId);
}
private static ServerCallContext CreateMockContext(CancellationToken cancellationToken = default)
{
var context = Substitute.For<ServerCallContext>();
context.CancellationToken.Returns(cancellationToken);
return context;
}
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition() && DateTime.UtcNow < deadline)
{
await Task.Delay(25);
}
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
}
}

View File

@@ -13,6 +13,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />

View File

@@ -19,8 +19,8 @@ public class SiteStreamManagerTests : TestKit, IDisposable
{
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
_streamManager = new SiteStreamManager(
Sys, options, NullLogger<SiteStreamManager>.Instance);
_streamManager.Initialize();
options, NullLogger<SiteStreamManager>.Instance);
_streamManager.Initialize(Sys);
}
void IDisposable.Dispose()