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");
}
}