Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
T
Joseph Doherty 6c11362d9f fix(auditlog)+fix(siteeventlog): complete audit write tasks only after commit; run event-log retention on standby (arch-review 2026-08-19 P4-2 + P4-4)
P4-2: SqliteAuditWriter.FlushBatch completed each pending row's Task
(TrySetResult) inside the per-row insert loop, before transaction.Commit()
ran. A later failure in the same batch (another row's insert throwing, or
the commit itself failing) rolled the whole transaction back, but the
earlier rows' Tasks had already completed successfully -- the caller and
FallbackAuditWriter (whose ring-buffer fallback exists precisely for this
outage) both saw false success while the rows were actually gone from the
database; the outer catch's TrySetException became a silent no-op against
an already-completed TaskCompletionSource. Every row's Completion is now
deferred and only resolved (TrySetResult for the whole batch, or
TrySetException for the whole batch) after transaction.Commit() succeeds
or the transaction rolls back, mirroring SiteEventLogger.FlushBatch.

P4-4: EventLogPurgeService gated the daily site_events purge on the
active-node check, early-exiting on the standby, on the stale premise that
"the standby receives no writes". DataConnectionActor runs on both site
nodes and writes site_events regardless of active/standby status (register
rows 89/90/97), so an unreplicated standby (the rig's site-b/site-c
posture) grew its site_events file forever with no retention and no cap.
The gate is removed; every node now purges its own file every tick. Safe
on a replicated pair because the retention/cap deletes are CDC-captured
and converge under LWW regardless of which node issues them; required on
an unreplicated pair because each node's file is independent. The
active-node check is retained only to annotate the purge tick's log line.

Tests: new SqliteAuditWriterCommitFailureTests forces a genuine
non-swallowed mid-batch INSERT failure via a real SQLite trigger + custom
scalar function on the writer's own connection (reflection seam), proving
(a) a row whose own insert succeeded still faults when a later row in the
same batch fails, and (b) FallbackAuditWriter rings every faulted row and
increments the failure counter for each -- both assertions fail against
the pre-fix code (proof-of-failure verified by temporarily reverting the
fix). EventLogPurgeServiceTests' standby case is inverted to assert the
purge now runs (not skips) on a standby node.
2026-08-19 14:49:24 -04:00

571 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 check must be registered on site nodes so
// EventLogPurge's log line can report whether this node is active (arch-review
// 2026-08-19 P4-4: it no longer gates which node purges — every node purges its
// own site_events file).
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) },
};
}