diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs index ceee9a94..25de396a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs @@ -178,11 +178,26 @@ public sealed class DriverProbeHandshakeE2eTests // ControlLogix instead returns ErrorNotFound, which the probe classifies as // reachable). Whether the `@raw_cpu_type` system-tag fallback is valid on a real // ControlLogix is a hardware-gated follow-up (AbCipDriverOptions.cs flags it deferred). + // ⚠️ The config must use the v3 `RawTags` shape — `[{RawPath, TagConfig, WriteIdempotent, + // DeviceName}]`, where the Logix tag path lives inside the TagConfig blob as `tagPath`. This + // test passed the retired v2 `Tags: [{DeviceHostAddress, TagPath}]` shape until 2026-08-01, so + // AbCipDriverOptions.RawTags bound EMPTY, the probe fell through to its `@raw_cpu_type` system-tag + // fallback, and ab_server answered ErrorBadParam. Which is to say it spent that whole time + // exercising the one path the comment above says it does not exercise — and reporting it as a + // failure of the fixture rather than of the config shape. var result = await new AbCipDriverProbe().ProbeAsync( $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," + - $"\"Tags\":[{{\"DeviceHostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\",\"TagPath\":\"TestDINT\"}}]}}", + $"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," + + $"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," + + $"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}", Timeout, Ct); result.Ok.ShouldBeTrue($"Probe message: {result.Message}"); - result.Message!.ShouldContain("CIP session OK"); + + // EXACT, not ShouldContain. The probe's two green messages are "CIP session OK" (the tag was + // read) and "CIP session OK (controller reachable; probe tag not found)" (the fallback), and + // `ShouldContain` matches both — so it would have gone green again the moment the config shape + // was merely well-formed enough to reach the controller, whether or not the authored tag was + // ever resolved. Reading the authored tag is the entire point of this test. + result.Message.ShouldBe("CIP session OK"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs index 61d4a148..085f6d31 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs @@ -14,29 +14,20 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// admin path — not only the driver path — or every Test Connect button returns /// "No probe registered for driver type X". /// +/// +/// ⚠️ The expected set is derived from , not hand-written. +/// It used to be a literal array kept "in sync" by comment, and it drifted the moment `Sql` +/// (9b5a9687) and `Mqtt` (f79d13e2) shipped: the count assertion below went red +/// reporting 12 where 10 expected — a true statement that named neither driver and read like +/// the probes were wrong rather than the list. Worse is the direction it could NOT catch: a new +/// driver type registered with no probe passed silently, because a hand-written list grows +/// only when someone remembers to grow it, and this test exists precisely for the case where nobody +/// did. Deriving from the constants makes the guard bidirectional and self-maintaining. +/// public sealed class DriverProbeRegistrationTests { - // The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey. - // Keep in sync with the IDriverProbe registrations in - // src/Server/.../Host/Drivers/DriverFactoryBootstrap.AddOtOpcUaDriverProbes (the routed - // *DriverPage.razor flow was retired in v3 Batch 2 — the driver-type keys now live in the /raw - // modals, but the probe set is the authority for what the AdminUI can Test-connect). - private static readonly string[] AdminUiDriverTypeKeys = - [ - "Modbus", - "AbCip", - "AbLegacy", - "S7", - "TwinCat", // page key; probe reports "TwinCAT" — must resolve case-insensitively - "Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively - "OpcUaClient", - "GalaxyMxGateway", - "Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap - "MTConnect", // MTConnectProbe — read-only Agent driver; probe issues a bare GET /probe - ]; - [Fact] - public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_AdminUI_driver_type() + public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_driver_type() { var services = new ServiceCollection(); services.AddOtOpcUaDriverProbes(); @@ -48,8 +39,50 @@ public sealed class DriverProbeRegistrationTests // (case-insensitive) and would throw on a duplicate key, crashing the singleton. var byType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase); - foreach (var key in AdminUiDriverTypeKeys) - byType.ContainsKey(key).ShouldBeTrue($"No IDriverProbe registered for AdminUI driver type '{key}'."); + var missing = DriverTypeNames.All.Where(t => !byType.ContainsKey(t)).ToList(); + missing.ShouldBeEmpty( + "Driver types with no IDriverProbe — Test Connect returns 'No probe registered for driver " + + $"type X' for each: {string.Join(", ", missing)}"); + } + + [Fact] + public void No_probe_is_registered_for_a_driver_type_that_does_not_exist() + { + // The other direction. A probe whose DriverType matches no DriverTypeNames constant can never + // be reached — AdminOperationsActor looks probes up by the authored DriverType — so it is a + // typo or a leftover from a renamed driver, and it reads as coverage while providing none. + var services = new ServiceCollection(); + services.AddOtOpcUaDriverProbes(); + + using var sp = services.BuildServiceProvider(); + + var orphans = sp.GetServices() + .Select(p => p.DriverType) + .Where(t => !DriverTypeNames.All.Contains(t, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + orphans.ShouldBeEmpty($"Probes for unknown driver types: {string.Join(", ", orphans)}"); + } + + /// + /// Two driver types are spelled differently by the constant and by the AdminUI's own key + /// (TwinCAT/TwinCat, FOCAS/Focas), so the lookup must fold case. The + /// derived tests above cannot cover this — they compare the constants against themselves. + /// + [Theory] + [InlineData("TwinCat", DriverTypeNames.TwinCAT)] + [InlineData("Focas", DriverTypeNames.FOCAS)] + [InlineData("modbus", DriverTypeNames.Modbus)] + public void A_probe_resolves_by_a_case_drifted_driver_type_key(string authoredKey, string canonical) + { + var services = new ServiceCollection(); + services.AddOtOpcUaDriverProbes(); + + using var sp = services.BuildServiceProvider(); + var byType = sp.GetServices().ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase); + + byType.ShouldContainKey(authoredKey); + byType[authoredKey].DriverType.ShouldBe(canonical); } [Fact] @@ -67,6 +100,6 @@ public sealed class DriverProbeRegistrationTests var distinctTypes = probes.Select(p => p.DriverType).Distinct(StringComparer.OrdinalIgnoreCase).Count(); probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup."); - distinctTypes.ShouldBe(AdminUiDriverTypeKeys.Length); + distinctTypes.ShouldBe(DriverTypeNames.All.Count); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs index 9e2558b5..1d68ce50 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs @@ -423,6 +423,21 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable ab.WithOtOpcUaClusterBootstrap(sp); ab.WithOtOpcUaControlPlaneSingletons(); ab.WithOtOpcUaRuntimeActors(); + + // Production fidelity, per-cluster mesh Phase 6 (`a2c5d57f`). RedundancyStateActor moved OUT + // of WithOtOpcUaControlPlaneSingletons and into its own cluster-role-scoped registration, + // which Program.cs makes from its DRIVER branch — a driver-only site pair has no admin node + // to elect its Primary. The harness models a fused admin,driver node but was never updated, + // so from that commit until now NO node here spawned the singleton: nothing published + // `redundancy-state`, every node sat at "role unknown" forever, and PrimaryGateFailoverTests + // polled for 45s and timed out. + // + // This is the second time this exact shape has bitten the harness — see the AddOtOpcUaRuntime + // note above, where calling only the Akka-spawn half left IDriverHealthPublisher on its Null + // default. When Program.cs grows an actor registration, this list has to grow with it; the + // harness's whole value is that it is wired the way a real node is. + ab.WithOtOpcUaClusterRedundancySingleton( + sp.GetRequiredService>().Value); }); builder.Services.AddOtOpcUaAuth(builder.Configuration);