using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Redundancy; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Redundancy; /// /// Covers the Trigger-failover control's behaviour. The repo has no bUnit (see /// PageAuthorizationGuardTests), so the consequential parts — the peer guard, the confirm /// flow, and the refused-vs-succeeded outcome — live in a pure model that the razor is a shell over, /// and are tested here rather than left to live verification alone. /// public sealed class ManualFailoverPageModelTests { private sealed class FakeFailoverService : IManualFailoverService { public ManualFailoverSnapshot Next { get; set; } = new("akka.tcp://otopcua@a:4053", new[] { "akka.tcp://otopcua@a:4053", "akka.tcp://otopcua@b:4053", }); public int FailOverCalls { get; private set; } public List Actors { get; } = new(); public string? Result { get; set; } = "akka.tcp://otopcua@a:4053"; public Exception? Throw { get; set; } public Exception? ThrowOnSnapshot { get; set; } public ManualFailoverSnapshot GetSnapshot() => ThrowOnSnapshot is not null ? throw ThrowOnSnapshot : Next; public Task FailOverDriverPrimaryAsync(string actor) { FailOverCalls++; Actors.Add(actor); if (Throw is not null) throw Throw; return Task.FromResult(Result); } } private static (ManualFailoverPageModel Model, FakeFailoverService Service) Build() { var svc = new FakeFailoverService(); var model = new ManualFailoverPageModel(svc); model.Refresh(); return (model, svc); } /// /// The control is Administrator-only. Not — which the /// neighbouring cluster-authoring pages use, and which also admits Designer: this restarts a /// production node rather than editing configuration. /// [Fact] public void Control_is_gated_on_the_fleet_admin_policy() { ManualFailoverPageModel.RequiredPolicy.ShouldBe(AdminUiPolicies.FleetAdmin); ManualFailoverPageModel.RequiredPolicy.ShouldNotBe(AdminUiPolicies.ConfigEditor); } /// With a peer present the button is live and explains nothing away. [Fact] public void Button_is_enabled_when_a_driver_peer_exists() { var (model, _) = Build(); model.CanFailOver.ShouldBeTrue(); model.DisabledReason.ShouldBeNull(); model.Snapshot!.PrimaryAddress.ShouldBe("akka.tcp://otopcua@a:4053"); } /// /// THE peer guard. On a lone driver node a "failover" is a shutdown: the button must be disabled /// and say why, and requesting it must not even open the dialog. /// [Fact] public void Button_is_disabled_with_a_reason_when_there_is_no_peer() { var (model, svc) = Build(); svc.Next = new ManualFailoverSnapshot("akka.tcp://otopcua@a:4053", new[] { "akka.tcp://otopcua@a:4053" }); model.Refresh(); model.CanFailOver.ShouldBeFalse(); model.DisabledReason.ShouldNotBeNull().ShouldContain("1 Up driver member"); model.RequestFailover(); model.ConfirmOpen.ShouldBeFalse("a guarded-off control must not open its confirmation dialog"); } /// The confirm flow calls the service exactly once, with the authenticated user. [Fact] public async Task Confirm_flow_calls_the_service_exactly_once() { var (model, svc) = Build(); model.RequestFailover(); model.ConfirmOpen.ShouldBeTrue(); await model.ConfirmFailoverAsync("alice"); svc.FailOverCalls.ShouldBe(1); svc.Actors.ShouldBe(new[] { "alice" }); model.ConfirmOpen.ShouldBeFalse(); model.StatusIsError.ShouldBeFalse(); model.StatusMessage.ShouldNotBeNull().ShouldContain("akka.tcp://otopcua@a:4053"); } /// /// Confirming without an open dialog does nothing — a stray double-submit on the circuit must not /// bounce a second node. /// [Fact] public async Task Confirming_twice_only_fails_over_once() { var (model, svc) = Build(); model.RequestFailover(); await model.ConfirmFailoverAsync("alice"); await model.ConfirmFailoverAsync("alice"); svc.FailOverCalls.ShouldBe(1); } /// Cancelling closes the dialog and calls nothing. [Fact] public async Task Cancel_does_not_call_the_service() { var (model, svc) = Build(); model.RequestFailover(); model.CancelFailover(); await model.ConfirmFailoverAsync("alice"); model.ConfirmOpen.ShouldBeFalse(); svc.FailOverCalls.ShouldBe(0); } /// /// The service re-evaluates the peer guard against live state, which can have changed since the /// page rendered. A refusal must surface as a failure — reporting it as a success would tell an /// operator the Primary moved when it did not. /// [Fact] public async Task Refusal_is_reported_as_an_error_not_a_success() { var (model, svc) = Build(); svc.Result = null; model.RequestFailover(); await model.ConfirmFailoverAsync("alice"); model.StatusIsError.ShouldBeTrue(); model.StatusMessage.ShouldNotBeNull().ShouldContain("refused"); } /// A throwing service surfaces as an error, not an unhandled circuit exception. [Fact] public async Task Service_failure_is_surfaced_not_thrown() { var (model, svc) = Build(); svc.Throw = new InvalidOperationException("cluster gone"); model.RequestFailover(); await model.ConfirmFailoverAsync("alice"); model.StatusIsError.ShouldBeTrue(); model.StatusMessage.ShouldNotBeNull().ShouldContain("cluster gone"); } /// /// An unreadable cluster renders a disabled control, not a 500 — the page is also the place an /// operator looks when the node is unhealthy. /// [Fact] public void Unreadable_cluster_state_disables_the_control_instead_of_throwing() { var svc = new FakeFailoverService { ThrowOnSnapshot = new InvalidOperationException("no cluster") }; var model = new ManualFailoverPageModel(svc); Should.NotThrow(model.Refresh); model.Snapshot.ShouldBeNull(); model.CanFailOver.ShouldBeFalse(); model.DisabledReason.ShouldNotBeNull().ShouldContain("unavailable"); } }