Merge fix/678-small: retention cancel/fallback tests, #672 tests, FOCAS race, TTL log wording, debug overlay, role-overlay suffix fix (#678)

This commit is contained in:
Joseph Doherty
2026-10-11 10:55:19 -04:00
13 changed files with 279 additions and 20 deletions
@@ -0,0 +1,78 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// #678.6 — the per-role appsettings overlay name is built from plain roles only. A <c>cluster-*</c> role in the
/// name produced a file that never exists, so no overlay ever loaded on a node with a cluster role.
/// </summary>
public sealed class RoleOverlayTests
{
[Fact]
public void A_driver_site_node_loads_the_driver_overlay()
=> RoleOverlay.Suffix(["driver", "cluster-SITE-A"]).ShouldBe("driver");
[Fact]
public void A_central_node_loads_the_admin_driver_overlay()
=> RoleOverlay.Suffix(["admin", "driver", "cluster-MAIN"]).ShouldBe("admin-driver");
[Fact]
public void Plain_roles_are_ordinal_sorted_regardless_of_input_order()
=> RoleOverlay.Suffix(["driver", "cluster-MAIN", "admin"]).ShouldBe("admin-driver");
[Fact]
public void Roles_without_a_cluster_role_keep_the_existing_names()
{
RoleOverlay.Suffix(["admin"]).ShouldBe("admin");
RoleOverlay.Suffix(["driver"]).ShouldBe("driver");
RoleOverlay.Suffix(["admin", "driver"]).ShouldBe("admin-driver");
}
[Fact]
public void A_cluster_role_prefix_is_matched_case_insensitively()
=> RoleOverlay.Suffix(["driver", "CLUSTER-SITE-A"]).ShouldBe("driver");
[Fact]
public void No_plain_role_means_no_overlay()
{
RoleOverlay.Suffix([]).ShouldBeNull();
RoleOverlay.Suffix(["cluster-SITE-A"]).ShouldBeNull();
}
// ---- The shipped overlays: activating them on cluster-role nodes must not change production semantics. ----
private static string HostDir()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host");
if (File.Exists(Path.Combine(candidate, "appsettings.json"))) return candidate;
}
throw new InvalidOperationException("Host project directory not found above " + AppContext.BaseDirectory);
}
[Theory]
[InlineData("admin")]
[InlineData("driver")]
[InlineData("admin-driver")]
public void Shipped_overlays_carry_only_the_audited_keys(string suffix)
{
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(HostDir(), $"appsettings.{suffix}.json")));
var root = doc.RootElement;
// Only Serilog + Security:Ldap, and in Security:Ldap only the two audited keys.
root.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["Security", "Serilog"]);
var ldap = root.GetProperty("Security").GetProperty("Ldap");
ldap.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["DevStubMode", "Transport"]);
ldap.GetProperty("DevStubMode").GetBoolean().ShouldBeFalse();
// No Debug-level (or lower) category override: activating an overlay must not raise log volume on a node.
foreach (var o in root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Override").EnumerateObject())
o.Value.GetString().ShouldNotBeOneOf("Debug", "Verbose");
root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Default").GetString().ShouldBe("Information");
}
}
@@ -223,10 +223,30 @@ public sealed class DriverHostActorHistorizationRoleViewTests : RuntimeActorTest
host.Tell(new ClusterEvent.UnreachableMember(MemberAt(Peer672)));
AwaitAssert(() => hist.Published.ShouldBe([false, true]), duration: Timeout);
// And self "returning" does not close what the peer's loss opened.
// Self "returning" is ignored too, and it does not close what the peer's loss opened. Events are
// processed in order, so the peer's return (which MUST close the gate) is the only thing that may
// appear next: had the self event published anything it would sit between `true` and `false`.
host.Tell(new ClusterEvent.ReachableMember(MemberAt(Self672)));
host.Tell(new ClusterEvent.ReachableMember(MemberAt(Peer672)));
AwaitAssert(() => hist.Published.ShouldBe([false, true, false]), duration: Timeout);
}
/// <summary>
/// A host that starts while its peer is ALREADY unreachable (the <c>InitialStateAsEvents</c> replay
/// delivers the <c>UnreachableMember</c> before the first redundancy snapshot) must still treat that
/// peer as unreachable when the snapshot naming it Primary finally arrives.
/// </summary>
[Fact]
public void A_peer_unreachable_before_the_first_snapshot_still_opens_capture()
{
var hist = new RecordingHistorizationView();
IActorRef host = SpawnHost672(hist, others: null, captureWhilePrimaryUnreachable: true);
host.Tell(new ClusterEvent.UnreachableMember(MemberAt(Peer672)));
host.Tell(Snapshot((Self672, RedundancyRole.Secondary), (Peer672, RedundancyRole.Primary)));
AwaitAssert(() => hist.Published.ShouldBe([false, true, true]), duration: Timeout);
// Exactly one publication, and it is `true`: no spurious `false` from treating the peer as reachable.
AwaitAssert(() => hist.Published.ShouldBe([true]), duration: Timeout);
}
/// <summary><c>CaptureWhilePrimaryUnreachable = false</c> restores the pre-#672 behaviour.</summary>