Files
scadalink-design/tests/ScadaLink.Host.Tests/ActorPathTests.cs
Joseph Doherty 1d495d1a87 feat(notification-outbox): register NotificationOutbox singleton in Host
Wire the Notification Outbox into the Host central role:
- Program.cs: call AddNotificationOutbox() on the central path (binds
  NotificationOutboxOptions via BindConfiguration; no explicit Configure).
- AkkaHostedService.RegisterCentralActors(): create the NotificationOutboxActor
  as a non-role-scoped central cluster singleton + proxy, then send
  RegisterNotificationOutbox(proxy) to the CentralCommunicationActor.
- appsettings.Central.json: add the ScadaLink:NotificationOutbox section with
  the NotificationOutboxOptions defaults.
- SiteServiceRegistration: remove the now-dead AddNotificationService() call -
  sites forward notifications to central rather than delivering over SMTP, and
  no site component consumes the SMTP machinery.
- Host.csproj: add the ScadaLink.NotificationOutbox project reference.
- Tests: add central outbox singleton/proxy actor-path assertions, drop the
  site OAuth2TokenService/INotificationDeliveryService resolution assertions,
  and add NotificationOutbox to the component-library IConfiguration check.
2026-05-19 02:44:32 -04:00

206 lines
8.0 KiB
C#

using Akka.Actor;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
namespace ScadaLink.Host.Tests;
[CollectionDefinition("ActorSystem")]
public class ActorSystemCollection : ICollectionFixture<object> { }
/// <summary>
/// Verifies that all expected Central-role actors are created at the correct paths
/// when AkkaHostedService starts.
/// </summary>
[Collection("ActorSystem")]
public class CentralActorPathTests : IAsyncLifetime
{
private WebApplicationFactory<Program>? _factory;
private ActorSystem? _actorSystem;
private string? _previousEnv;
public Task InitializeAsync()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:NodeHostname"] = "localhost",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:25510",
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:25520",
["ScadaLink:Cluster:MinNrOfMembers"] = "1",
["ScadaLink:Database:SkipMigrations"] = "true",
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
["ScadaLink:Security:LdapServer"] = "localhost",
["ScadaLink:Security:LdapPort"] = "3893",
["ScadaLink:Security:LdapUseTls"] = "false",
["ScadaLink:Security:AllowInsecureLdap"] = "true",
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
});
});
builder.UseSetting("ScadaLink:Node:Role", "Central");
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
builder.ConfigureServices(services =>
{
// Replace SQL Server with in-memory database
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(ScadaLinkDbContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
.ToList();
foreach (var d in descriptorsToRemove)
services.Remove(d);
services.AddDbContext<ScadaLinkDbContext>(options =>
options.UseInMemoryDatabase($"ActorPathTests_{Guid.NewGuid()}"));
});
});
// CreateClient triggers host startup including AkkaHostedService
_ = _factory.CreateClient();
var akkaService = _factory.Services.GetRequiredService<AkkaHostedService>();
_actorSystem = akkaService.ActorSystem;
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_factory?.Dispose();
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
await Task.CompletedTask;
}
[Fact]
public async Task CentralActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
[Fact]
public async Task CentralActors_CentralCommunication_Exists()
=> await AssertActorExists("/user/central-communication");
[Fact]
public async Task CentralActors_Management_Exists()
=> await AssertActorExists("/user/management");
[Fact]
public async Task CentralActors_NotificationOutboxSingleton_Exists()
=> await AssertActorExists("/user/notification-outbox-singleton");
[Fact]
public async Task CentralActors_NotificationOutboxProxy_Exists()
=> await AssertActorExists("/user/notification-outbox-proxy");
private async Task AssertActorExists(string path)
{
Assert.NotNull(_actorSystem);
var selection = _actorSystem!.ActorSelection(path);
var identity = await selection.Ask<ActorIdentity>(
new Identify(path), TimeSpan.FromSeconds(5));
Assert.NotNull(identity.Subject);
}
}
/// <summary>
/// Verifies that all expected Site-role actors are created at the correct paths
/// when AkkaHostedService starts.
/// </summary>
[Collection("ActorSystem")]
public class SiteActorPathTests : IAsyncLifetime
{
private IHost? _host;
private ActorSystem? _actorSystem;
private string _tempDbPath = null!;
public async Task InitializeAsync()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_actor_test_{Guid.NewGuid()}.db");
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "localhost",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:25510",
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:25520",
["ScadaLink:Cluster:MinNrOfMembers"] = "1",
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
// Configure a dummy central contact point to trigger ClusterClient creation
["ScadaLink:Communication:CentralContactPoints:0"] = "akka.tcp://scadalink@localhost:25510",
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
});
_host = builder.Build();
await _host.StartAsync();
var akkaService = _host.Services.GetRequiredService<AkkaHostedService>();
_actorSystem = akkaService.ActorSystem;
}
public async Task DisposeAsync()
{
if (_host != null)
{
await _host.StopAsync();
_host.Dispose();
}
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
[Fact]
public async Task SiteActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
[Fact]
public async Task SiteActors_DclManager_Exists()
=> await AssertActorExists("/user/dcl-manager");
[Fact]
public async Task SiteActors_DeploymentManagerSingleton_Exists()
=> await AssertActorExists("/user/deployment-manager-singleton");
[Fact]
public async Task SiteActors_DeploymentManagerProxy_Exists()
=> await AssertActorExists("/user/deployment-manager-proxy");
[Fact]
public async Task SiteActors_SiteCommunication_Exists()
=> await AssertActorExists("/user/site-communication");
[Fact]
public async Task SiteActors_CentralClusterClient_Exists()
=> await AssertActorExists("/user/central-cluster-client");
private async Task AssertActorExists(string path)
{
Assert.NotNull(_actorSystem);
var selection = _actorSystem!.ActorSelection(path);
var identity = await selection.Ask<ActorIdentity>(
new Identify(path), TimeSpan.FromSeconds(5));
Assert.NotNull(identity.Subject);
}
}