test(client): over-the-wire alarm method calls against the in-process server

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 01:49:32 -04:00
parent d978573fb2
commit d66269a99b
4 changed files with 146 additions and 2 deletions
@@ -0,0 +1,140 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// The shared client's alarm APIs (<see cref="OpcUaClientService" />, the code behind Client.CLI's
/// <c>ack</c>/<c>confirm</c>/<c>shelve</c>) against the real in-process server, over the wire, with the real
/// session adapter. Commit 85f00327 moved the client onto the Part 9 method ids invoked on the condition
/// node itself (it used to target nonexistent <c>&lt;id&gt;.Condition</c> / <c>&lt;id&gt;.ShelvingState</c>
/// node ids); until this class it was only exercised against a fake session adapter.
/// </summary>
/// <remarks>
/// The condition id is passed exactly as a CLI user passes <c>--node</c>: the condition node's own id.
/// Each call must return Good <b>and</b> move the condition — read back from the condition events the
/// server reports, not from the call result alone. The client exposes no AddComment API, so it is not covered.
/// </remarks>
public sealed class ClientAlarmMethodTests
{
private const NodePermissions All =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
| NodePermissions.MethodCall;
[Fact]
public async Task The_client_acknowledges_confirms_and_shelves_a_scripted_condition()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("ClientAlarmScripted", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseScripted();
var current = ConditionMethodEventTests.PreState("Acknowledge", native: false) with { Message = "base" };
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
using var observer = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(native: false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
using var client = await ConnectClientAsync(rig, ct);
var conditionId = ConditionNodeId(observer, native: false).ToString();
async Task<VariantCollection> Next(int expectedCount)
{
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= expectedCount);
return events.ConditionEvents(item, "base")[expectedCount - 1];
}
var n = 1;
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(
conditionId, events.LatestEventId(item, "base"), "ack", ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.AckedField].Value.ShouldBe(true);
StatusCode.IsGood(await client.ConfirmAlarmAsync(
conditionId, events.LatestEventId(item, "base"), "confirm", ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
[Fact]
public async Task The_client_acknowledges_a_native_condition_and_is_refused_confirm_and_shelve()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("ClientAlarmNative", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseNative();
var current = ConditionMethodEventTests.PreState("Acknowledge", native: true) with { Message = "base" };
ConditionMethodEventTests.WireSimulatedOwners(rig, native: true, () => current, s => current = s);
using var observer = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(native: true, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
using var client = await ConnectClientAsync(rig, ct);
var conditionId = ConditionNodeId(observer, native: true).ToString();
var eventId = events.LatestEventId(item, "base");
// Refusals first: they must not disturb the condition, so the ack below still lands as event #2.
(await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(conditionId, eventId, "ack", ct)).ShouldBeTrue();
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 2);
var ack = events.ConditionEvents(item, "base")[1];
ack[RecordedEvents.AckedField].Value.ShouldBe(true);
ack[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
ack[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
private static async Task<OpcUaClientService> ConnectClientAsync(ServerEventRig rig, CancellationToken ct)
{
var client = new OpcUaClientService();
await client.ConnectAsync(new ConnectionSettings
{
EndpointUrl = rig.EndpointUrl,
Username = "apikey",
Password = $"otua_{KeyId}_secret",
CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"),
}, ct);
return client;
}
}
@@ -394,7 +394,7 @@ public sealed class ConditionMethodEventTests
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
private static AlarmConditionSnapshot PreState(string method, bool native)
internal static AlarmConditionSnapshot PreState(string method, bool native)
{
var s = new AlarmConditionSnapshot(
Active: true, Acknowledged: false, Confirmed: native, Enabled: true,
@@ -414,7 +414,7 @@ public sealed class ConditionMethodEventTests
/// Stands in for the scripted engine and the native driver: each routed command comes back as a
/// re-projection of the state it produces, from a pool thread.
/// </summary>
private static void WireSimulatedOwners(
internal static void WireSimulatedOwners(
ServerEventRig rig, bool native, Func<AlarmConditionSnapshot> current, Action<AlarmConditionSnapshot> set)
{
rig.Nm.NativeAlarmAckRouter = _ =>
@@ -41,6 +41,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
public OtOpcUaNodeManager Nm => _server.NodeManager!;
public SdkAddressSpaceSink Sink { get; private set; } = null!;
@@ -29,6 +29,8 @@
over-the-wire denial is produced by the same component the hosted service arms rather than by
a direct call to the node manager's pass. -->
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<!-- ClientAlarmMethodTests drives the real shared client against this in-process server. -->
<ProjectReference Include="..\..\..\src\Client\ZB.MOM.WW.OtOpcUa.Client.Shared\ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
</ItemGroup>
</Project>