using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the admin role. /// A driver-only node (Akka role driver, NOT admin) boots with NO ConfigDb /// connection string at all; its configuration arrives from central via the Phase-3 /// ConfigSource:Mode=FetchAndCache path. /// /// /// /// These tests replicate the exact ConfigDb gating decision Program.cs makes — /// if (hasAdmin) services.AddOtOpcUaConfigDb(configuration) — over the real /// method, for both role shapes. /// They deliberately do NOT boot the full host: Program.cs reads roles from the /// OTOPCUA_ROLES process env var and starting the host triggers the Akka cluster join /// (see TwoNodeClusterHarness on why WebApplicationFactory<Program> is not /// driven here, and LocalDbWiringTests for the same partial-graph harness model). The /// full driver-only boot is proven by the Phase-4 live gate + the later DI-graph sweep task. /// /// public sealed class DriverOnlyNoConfigDbBootTests : IDisposable { private readonly List _apps = []; /// /// Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff /// . A driver-only node passes hasAdmin: false and supplies /// no ConnectionStrings:ConfigDb at all. /// private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); if (configDbConnectionString is not null) { builder.Configuration.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:ConfigDb"] = configDbConnectionString, }); } if (hasAdmin) builder.Services.AddOtOpcUaConfigDb(builder.Configuration); var app = builder.Build(); _apps.Add(app); return app.Services; } [Fact] public void DriverOnlyNode_NoConnectionString_BuildsWithoutThrowing_AndRegistersNoConfigDbFactory() { // The core Phase-4 guarantee: a driver-only node holds no ConfigDb connection string and must // still build its service graph without throwing — nothing in the shared registrations may // reach for the ConfigDb factory. IServiceProvider sp = null!; Should.NotThrow(() => sp = BuildSharedServicesGraph(hasAdmin: false, configDbConnectionString: null)); sp.GetService>().ShouldBeNull(); } [Fact] public void AdminNode_WithConnectionString_RegistersTheConfigDbFactory() { // The fused/admin path is byte-for-byte unaffected: ConfigDb is registered exactly as before. var sp = BuildSharedServicesGraph( hasAdmin: true, configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True"); sp.GetService>().ShouldNotBeNull(); } [Fact] public void AddOtOpcUaConfigDb_WithoutConnectionString_Throws() { // Why the gate exists: the unconditional call Program.cs made throws on a driver-only node, // which has no ConfigDb connection string. This pins the exact failure the hasAdmin gate avoids. var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); Should.Throw(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration)); } public void Dispose() { foreach (var app in _apps) ((IDisposable)app).Dispose(); } }