Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
T
Joseph Doherty 037798b367 feat(localdb)!: replicate site config + sf_messages via CDC, delete the bespoke replicators
Tasks 14, 15 and 16, landed as ONE commit.

PLAN DEFECT: these three tasks cannot compile separately. SiteReplicationActor
takes a ReplicationService and calls ReplaceAllAsync (Task 14 deletes both);
DeploymentManagerActor Tells message types declared in ReplicationMessages.cs
(Task 15 deletes it); AkkaHostedService constructs the actor (Task 16). Any
ordering leaves a broken intermediate. Combining them also strengthens the
invariant Task 14 already stated for itself — the two mechanisms never both
run, and never neither.

Registered 8 tables in SiteLocalDbSetup.OnReady: sf_messages plus the 7 site
config tables. notification_lists and smtp_configurations are deliberately NOT
registered — permanently empty by design, so registering them would open a
standing replication channel whose only historical payload was plaintext SMTP
passwords. Migrate stays the LAST call in OnReady, after all registrations, so
migrated rows enter the oplog through live capture triggers.

Deleted: SiteReplicationActor, ReplicationMessages.cs, ReplicationService,
StoreAndForwardStorage.ReplaceAllAsync, and 6 test files. ReplaceAllAsync is
not merely unused but unsafe to keep: a mass DELETE on a now-replicated table
would be captured and shipped to the peer.

Kept ActiveNodeEvaluator (delivery gate + heartbeat still need it) with its doc
corrected, and activeNodeCheck in AkkaHostedService (SiteCommunicationActor).

The positional-argument hazard the plan flagged was real: removing
DeploymentManagerActor's optional IActorRef? replicationActor shifted 6
trailing optionals, and 4 test call sites bound the wrong arguments with no
compile error at some positions. Converted them to named arguments where
possible — Props.Create builds an expression tree, which rejects out-of-position
named args, so the rest are padded positionally with a comment saying why.

The Task 7 'not yet registered' test was INVERTED rather than deleted, and is
exact in both directions: too few means a table silently stops replicating, too
many means the SMTP tables leak. Added a separate security-named test for those
two, and a composite-PK test (LWW keys on the full PK, so a truncated key set
would collapse distinct rows). The convergence suites now get their
registrations from the real OnReady — their temporary harness registration is
deleted, so they prove the cutover rather than agreeing with themselves.

Verified: build 0 warnings; SiteRuntime 512, StoreAndForward 130, Host 330,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97, LocalDb
integration 16 — all pass, 0 failures.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 04:20:05 -04:00

569 lines
25 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using ZB.MOM.WW.ScadaBridge.Host.Services;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Removes AkkaHostedService from running as a hosted service while keeping it
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
/// </summary>
internal static class AkkaHostedServiceRemover
{
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
{
// Pattern used in Program.cs:
// services.AddSingleton<AkkaHostedService>(); // index N
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
// We keep the singleton so DI resolution works, but remove the IHostedService
// factory so StartAsync is never called.
int akkaIndex = -1;
for (int i = 0; i < services.Count; i++)
{
if (services[i].ServiceType == typeof(AkkaHostedService))
{
akkaIndex = i;
break;
}
}
if (akkaIndex < 0) return;
// The IHostedService factory is the next registration after the singleton
for (int i = akkaIndex + 1; i < services.Count; i++)
{
if (services[i].ServiceType == typeof(IHostedService)
&& services[i].ImplementationFactory != null)
{
services.RemoveAt(i);
break;
}
}
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Central composition root.
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
/// </summary>
[Collection(HostBootCollection.Name)]
public class CentralCompositionRootTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
private readonly CentralDbTestEnvironment _dbEnv;
public CentralCompositionRootTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
// Supply the pepper, MachineDataDb and the G-4 secret-backed keys
// (ConfigurationDb / LDAP service-account password / JWT signing key) as
// whole-key env overrides so the pre-host config builder — which calls
// AddEnvironmentVariables() before the ${secret:...} expander runs — sees
// concrete values and the Central-role StartupValidator preflight passes.
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
// ServiceAccountDn is now required by the library's LdapOptionsValidator
// (ValidateOnStart), so it must be present for the host to start.
["ScadaBridge:Security:Ldap:Server"] = "localhost",
["ScadaBridge:Security:Ldap:Port"] = "3893",
["ScadaBridge:Security:Ldap:Transport"] = "None",
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
// Auth re-arch (C5): inbound-API keys live in the shared
// ZB.MOM.WW.Auth.ApiKeys SQLite store. The verifier reuses
// this same config key as its pepper secret (PepperSecretName),
// and AddZbApiKeyAuth fails fast if it is missing/weak — so a
// configured pepper is still required for the host to start.
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
builder.ConfigureServices(services =>
{
// Replace SQL Server with in-memory database
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
.ToList();
foreach (var d in descriptorsToRemove)
services.Remove(d);
services.AddDbContext<ScadaBridgeDbContext>(options =>
options.UseInMemoryDatabase($"CompositionRootTests_{Guid.NewGuid()}"));
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
});
});
// Trigger host build
_ = _factory.Server;
}
public void Dispose()
{
// try/finally: a throwing host teardown must not strand the process-wide
// environment variables for the rest of the run.
try
{
_factory.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
_dbEnv.Dispose();
}
}
// --- Singletons ---
[Theory]
[MemberData(nameof(CentralSingletonServices))]
public void Central_ResolveSingleton(Type serviceType)
{
var service = _factory.Services.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
new object[] { typeof(ISiteHealthCollector) },
new object[] { typeof(CentralHealthAggregator) },
new object[] { typeof(ICentralHealthAggregator) },
new object[] { typeof(OperationLockManager) },
new object[] { typeof(OAuth2TokenService) },
new object[] { typeof(InboundScriptExecutor) },
// Security: the shared ZB.MOM.WW.Auth.Ldap service is registered as a stateless
// singleton by AddZbLdapAuth (Task 1.2), replacing the old scoped LdapAuthService.
new object[] { typeof(ILdapAuthService) },
};
// --- Scoped services ---
[Theory]
[MemberData(nameof(CentralScopedServices))]
public void Central_ResolveScoped(Type serviceType)
{
using var scope = _factory.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralScopedServices => new[]
{
// TemplateEngine
new object[] { typeof(TemplateService) },
new object[] { typeof(SharedScriptService) },
new object[] { typeof(InstanceService) },
new object[] { typeof(SiteService) },
new object[] { typeof(AreaService) },
new object[] { typeof(TemplateDeletionService) },
// DeploymentManager
new object[] { typeof(IFlatteningPipeline) },
new object[] { typeof(DeploymentService) },
new object[] { typeof(ArtifactDeploymentService) },
// Security (ILdapAuthService is now a singleton — see CentralSingletonServices)
new object[] { typeof(JwtTokenService) },
new object[] { typeof(RoleMapper) },
// InboundAPI — auth re-arch (C5): the legacy ApiKeyValidator was retired;
// inbound auth runs through the shared ZB.MOM.WW.Auth.ApiKeys verifier.
new object[] { typeof(RouteHelper) },
// ExternalSystemGateway
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
// NotificationService — central-only SMTP primitives. The orphan
// NotificationDeliveryService + INotificationDeliveryService were removed
// (NS-019) when sites stopped delivering notifications; the central
// EmailNotificationDeliveryAdapter is now the only resolver of these
// primitives.
new object[] { typeof(Func<ISmtpClientWrapper>) },
new object[] { typeof(OAuth2TokenService) },
// ConfigurationDatabase repositories
new object[] { typeof(ScadaBridgeDbContext) },
new object[] { typeof(ISecurityRepository) },
new object[] { typeof(ICentralUiRepository) },
new object[] { typeof(ITemplateEngineRepository) },
new object[] { typeof(IDeploymentManagerRepository) },
new object[] { typeof(ISiteRepository) },
new object[] { typeof(IExternalSystemRepository) },
new object[] { typeof(INotificationRepository) },
new object[] { typeof(IInboundApiRepository) },
new object[] { typeof(IAuditService) },
new object[] { typeof(IInstanceLocator) },
// CentralUI
new object[] { typeof(AuthenticationStateProvider) },
};
// --- Transient services ---
[Theory]
[MemberData(nameof(CentralTransientServices))]
public void Central_ResolveTransient(Type serviceType)
{
using var scope = _factory.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralTransientServices => new[]
{
new object[] { typeof(FlatteningService) },
new object[] { typeof(DiffService) },
new object[] { typeof(RevisionHashService) },
new object[] { typeof(ScriptCompiler) },
new object[] { typeof(SemanticValidator) },
new object[] { typeof(ValidationService) },
};
// --- Options ---
[Theory]
[MemberData(nameof(CentralOptions))]
public void Central_ResolveOptions(Type optionsType)
{
var service = _factory.Services.GetService(optionsType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
new object[] { typeof(IOptions<ClusterOptions>) },
new object[] { typeof(IOptions<DatabaseOptions>) },
new object[] { typeof(IOptions<CommunicationOptions>) },
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
new object[] { typeof(IOptions<NotificationOptions>) },
new object[] { typeof(IOptions<LoggingOptions>) },
new object[] { typeof(IOptions<SecurityOptions>) },
new object[] { typeof(IOptions<InboundApiOptions>) },
new object[] { typeof(IOptions<ManagementServiceOptions>) },
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
};
// --- Hosted services ---
[Fact]
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
{
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
}
// --- InboundAPI-022 regression ---
/// <summary>
/// InboundAPI-022 regression: the Central composition root MUST register a
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
/// standby central node continues to serve the inbound API, racing the
/// active node and executing scripts against stale singleton state. The
/// design's "central cluster only (active node)" guarantee is enforced only
/// when the production gate is wired here.
///
/// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
/// — a registration the framework cannot resolve to a concrete instance is
/// indistinguishable from "missing" at runtime, which is the failure mode
/// the finding describes.
/// </summary>
[Fact]
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
{
var gate = _factory.Services.GetService<IActiveNodeGate>();
Assert.NotNull(gate);
Assert.IsType<ActiveNodeGate>(gate);
}
// --- ScadaBridge#22 regression ---
/// <summary>
/// ScadaBridge#22 regression: the Secrets.Ui components mounted at /admin/secrets inject
/// <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> — the SHARED-lib seam, not the repo's own
/// <see cref="IAuditWriter"/> (deliberately distinct per its doc comment). The secrets
/// adoption mounted the UI without bridging that seam, so component activation threw and
/// the page 500'd on every render; the login wall hid it. This resolves the full
/// injection set of every Secrets.Ui component (SecretsList / SecretEditor /
/// RevealButton / ConfirmDeleteModal) out of the REAL central composition root, because
/// the gap is invisible until the graph is actually built — the same defect class as the
/// four caught in the secrets library itself.
/// </summary>
[Theory]
[InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretStore))]
[InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretCipher))]
[InlineData(typeof(ZB.MOM.WW.Audit.IAuditWriter))]
public void Central_Resolves_SecretsUiComponentDependencies(Type serviceType)
{
var service = _factory.Services.GetService(serviceType);
Assert.NotNull(service);
}
/// <summary>
/// The bridge must route secrets-UI audit events into the CENTRAL direct-write path
/// (<see cref="ICentralAuditWriter"/> → dbo.AuditLog), not the site SQLite chain — the
/// site writer chain is registered on central but by design never resolved there, and
/// events written to it would dead-end in a local file no forwarder ever drains.
/// </summary>
[Fact]
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
{
var writer = _factory.Services.GetService<ZB.MOM.WW.Audit.IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<CentralSharedSeamAuditWriter>(writer);
}
}
/// <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 (WebApplicationBuilder + gRPC).
/// </summary>
public class SiteCompositionRootTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
private readonly string _tempTrackingDbPath;
private readonly string _tempLocalDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Node:GrpcPort"] = "0",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// Retained for the site config DB; it no longer points the tracking store
// anywhere — that store now opens the consolidated database below.
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
// The consolidated site database. IOperationTrackingStore resolves ILocalDb
// and creates the file eagerly in its ctor, so this must be a temp path or
// the composition-root test litters the working directory.
["LocalDb:Path"] = _tempLocalDbPath,
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
});
// gRPC server registration (mirrors Program.cs site section)
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.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 as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ }
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
[Fact]
public void Site_Resolves_IOperationTrackingStore()
{
// Site Call Audit's site-local source of truth. AkkaHostedService and
// ScriptRuntimeContext resolve this with GetService and silently degrade
// when absent (audit-only telemetry, no cached-drain, no PullSiteCalls) —
// so its presence on the SITE composition root must be locked here
// (arch-review 08 round 2 NF4/T8: AddSiteRuntime never registered it,
// despite two comments claiming it did).
var store = _host.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>();
Assert.NotNull(store);
}
// --- Singletons ---
[Theory]
[MemberData(nameof(SiteSingletonServices))]
public void Site_ResolveSingleton(Type serviceType)
{
var service = _host.Services.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
new object[] { typeof(ISiteHealthCollector) },
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) },
new object[] { typeof(ISiteEventLogger) },
new object[] { typeof(IEventLogQueryService) },
new object[] { typeof(ISiteIdentityProvider) },
new object[] { typeof(IHealthReportTransport) },
// M2.15 (#29): the active-node purge gate must be registered on site nodes
// so EventLogPurge only runs on the active node.
new object[] { typeof(SiteEventLogActiveNodeCheck) },
};
// --- Scoped services ---
[Theory]
[MemberData(nameof(SiteScopedServices))]
public void Site_ResolveScoped(Type serviceType)
{
using var scope = _host.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteScopedServices => new[]
{
new object[] { typeof(IExternalSystemRepository) },
// INotificationRepository is intentionally absent — notification config is
// central-only (arch-review 08 §1.3/#23); the site-side variant was removed.
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
};
// --- Implementation type assertions ---
[Fact]
public void Site_ExternalSystemRepository_IsSiteImplementation()
{
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
Assert.IsType<SiteExternalSystemRepository>(repo);
}
[Fact]
public void Site_NotificationRepository_IsNotRegistered()
{
// Notification delivery is central-only (CLAUDE.md "Notification delivery is
// central-only"); the site-local notification_lists/smtp_configurations tables
// are purged on every artifact apply, so a site-side INotificationRepository
// could only ever serve empty results. It must not be registered at all.
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetService<INotificationRepository>();
Assert.Null(repo);
}
// --- Options ---
[Theory]
[MemberData(nameof(SiteOptions))]
public void Site_ResolveOptions(Type optionsType)
{
var service = _host.Services.GetService(optionsType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
new object[] { typeof(IOptions<ClusterOptions>) },
new object[] { typeof(IOptions<DatabaseOptions>) },
new object[] { typeof(IOptions<CommunicationOptions>) },
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
new object[] { typeof(IOptions<NotificationOptions>) },
new object[] { typeof(IOptions<LoggingOptions>) },
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
new object[] { typeof(IOptions<DataConnectionOptions>) },
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
new object[] { typeof(IOptions<SiteEventLogOptions>) },
};
// --- Hosted services ---
[Theory]
[MemberData(nameof(SiteHostedServices))]
public void Site_HostedServiceRegistered(Type expectedType)
{
var hostedServices = _host.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
}
public static IEnumerable<object[]> SiteHostedServices => new[]
{
new object[] { typeof(HealthReportSender) },
new object[] { typeof(SiteStorageInitializer) },
new object[] { typeof(EventLogPurgeService) },
};
}