diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs
new file mode 100644
index 0000000..a5320df
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AlarmSubscribeIntegrationTests.cs
@@ -0,0 +1,268 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Opc.Ua.Configuration;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Server.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
+
+///
+/// Task #219 — server-integration coverage for the wiring
+/// path. Boots the full OPC UA stack + a fake driver, opens a
+/// client session, and verifies via browse/read that DiscoverAsync's
+/// MarkAsAlarmCondition calls produce addressable AlarmConditionState nodes in the
+/// driver's namespace and that firing OnAlarmEvent routes through
+/// GenericDriverNodeManager's forwarder into DriverNodeManager.ConditionSink
+/// without throwing.
+///
+/// Companion to which covers the
+/// dispatch path; together they close the server-side
+/// integration gap for optional driver capabilities (plan decision #62).
+///
+/// Known server-side scoping (not a regression introduced here): the stack exposes the
+/// AlarmConditionState type's inherited children (Severity / Message / ActiveState / …)
+/// with Foundation-namespace NodeIds (ns=0) that aren't added to
+/// 's predefined-node index, so reading those child
+/// attributes through an OPC UA client returns BadNodeIdUnknown. OPC UA Part 9
+/// event propagation (subscribe-on-Server + ConditionRefresh) is likewise out of scope
+/// until the node manager wires HasNotifier + child-node registration. The
+/// existing Core-level GenericDriverNodeManagerTests cover the in-memory alarm-sink
+/// fan-out semantics directly.
+///
+[Trait("Category", "Integration")]
+public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
+{
+ private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
+ private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
+ private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");
+
+ private DriverHost _driverHost = null!;
+ private OpcUaApplicationHost _server = null!;
+ private AlarmDriver _driver = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ _driverHost = new DriverHost();
+ _driver = new AlarmDriver();
+ await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
+
+ var options = new OpcUaServerOptions
+ {
+ EndpointUrl = _endpoint,
+ ApplicationName = "OtOpcUaAlarmTest",
+ ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
+ PkiStoreRoot = _pkiRoot,
+ AutoAcceptUntrustedClientCertificates = true,
+ HealthEndpointsEnabled = false,
+ };
+
+ _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
+ NullLoggerFactory.Instance, NullLogger.Instance);
+ await _server.StartAsync(CancellationToken.None);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _server.DisposeAsync();
+ await _driverHost.DisposeAsync();
+ try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
+ }
+
+ [Fact]
+ public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
+ {
+ using var session = await OpenSessionAsync();
+ var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
+
+ _driver.RaiseAlarm(new AlarmEventArgs(
+ SubscriptionHandle: new FakeHandle("sub"),
+ SourceNodeId: "Tank.HiHi",
+ ConditionId: "cond-1",
+ AlarmType: "Active",
+ Message: "Level exceeded upper-upper",
+ Severity: AlarmSeverity.High,
+ SourceTimestampUtc: DateTime.UtcNow));
+
+ // The alarm-condition node's identifier is the driver full-reference + ".Condition"
+ // (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
+ // are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
+ // so by the time RaiseAlarm returns the node state has been flushed.
+ var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);
+
+ // Browse the condition node for the well-known Part-9 child variables. The stack
+ // materializes Severity / Message / ActiveState / AckedState as children below the
+ // AlarmConditionState; their NodeIds are allocated by the stack so we discover them
+ // by BrowseName rather than guessing.
+ var browseDescriptions = new BrowseDescriptionCollection
+ {
+ new()
+ {
+ NodeId = conditionNodeId,
+ BrowseDirection = BrowseDirection.Forward,
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IncludeSubtypes = true,
+ NodeClassMask = 0,
+ ResultMask = (uint)BrowseResultMask.All,
+ },
+ };
+ session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
+ var children = browseResults[0].References
+ .ToDictionary(r => r.BrowseName.Name,
+ r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
+ StringComparer.Ordinal);
+
+ children.ShouldContainKey("Severity",
+ "browse did not return Severity child; full list: "
+ + string.Join(", ", browseResults[0].References.Select(r => $"{r.BrowseName.Name}={r.NodeId}")));
+ children.ShouldContainKey("Message");
+ children.ShouldContainKey("ActiveState");
+
+ // NB: the stack exposes AlarmConditionState's inherited children with Foundation-namespace
+ // NodeIds (ns=0). The DriverNodeManager registers only the parent alarm object via
+ // AddPredefinedNode; reading child attributes through the OPC UA client returns
+ // BadNodeIdUnknown because the stack-assigned child NodeIds aren't in the node
+ // manager's predefined-node index. Asserting state-mutation via a client-side read
+ // is therefore out of reach at this integration layer — the Core-level
+ // GenericDriverNodeManagerTests cover the in-memory alarm-sink fan-out directly.
+ //
+ // What this test *does* verify through the OPC UA client: the alarm node itself is
+ // reachable via browse (proving MarkAsAlarmCondition registered it as a predefined
+ // node), its displayed name matches the driver's AlarmConditionInfo.SourceName, and
+ // firing the transition does not throw out of ConditionSink.OnTransition (which would
+ // fail the test at RaiseAlarm since the event handler is invoked synchronously).
+ var nodesToRead = new ReadValueIdCollection
+ {
+ new() { NodeId = conditionNodeId, AttributeId = Attributes.DisplayName },
+ };
+ session.Read(null, 0, TimestampsToReturn.Neither, nodesToRead, out var values, out _);
+ values[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
+ ((LocalizedText)values[0].Value).Text.ShouldBe("Tank.HiHi");
+ }
+
+ [Fact]
+ public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
+ {
+ // Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
+ // MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
+ // a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
+ // under the driver full-reference. Browse should show both condition nodes with
+ // distinct NodeIds using the FullReference + ".Condition" convention.
+ using var session = await OpenSessionAsync();
+ var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
+
+ _driver.RaiseAlarm(new AlarmEventArgs(
+ new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
+ DateTime.UtcNow));
+
+ var attrs = new ReadValueIdCollection
+ {
+ new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
+ new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
+ };
+ session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
+ results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
+ results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
+ ((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
+ ((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
+ }
+
+ private async Task OpenSessionAsync()
+ {
+ var cfg = new ApplicationConfiguration
+ {
+ ApplicationName = "OtOpcUaAlarmTestClient",
+ ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ ApplicationCertificate = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(_pkiRoot, "client-own"),
+ SubjectName = "CN=OtOpcUaAlarmTestClient",
+ },
+ TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
+ TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
+ RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
+ AutoAcceptUntrustedCertificates = true,
+ AddAppCertToTrustedStore = true,
+ },
+ TransportConfigurations = new TransportConfigurationCollection(),
+ TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
+ };
+ await cfg.Validate(ApplicationType.Client);
+ cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
+
+ var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
+ await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
+
+ var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
+ var endpointConfig = EndpointConfiguration.Create(cfg);
+ var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
+
+ return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
+ new UserIdentity(new AnonymousIdentityToken()), null);
+ }
+
+ ///
+ /// Stub driver. emits two alarm-
+ /// bearing variables (so tag-scoped fan-out can be asserted);
+ /// fires exactly like a real driver would.
+ ///
+ private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
+ {
+ public string DriverInstanceId => "alarm-driver";
+ public string DriverType => "AlarmStub";
+
+ public event EventHandler? OnAlarmEvent;
+
+ public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
+ public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
+ public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
+ public long GetMemoryFootprint() => 0;
+ public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
+
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
+ {
+ var tank = builder.Folder("Tank", "Tank");
+ var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
+ "Tank.HiHi", DriverDataType.Boolean, false, null,
+ SecurityClassification.FreeAccess, false, IsAlarm: true));
+ hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
+ "Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
+
+ var heater = builder.Folder("Heater", "Heater");
+ var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
+ "Heater.OverTemp", DriverDataType.Boolean, false, null,
+ SecurityClassification.FreeAccess, false, IsAlarm: true));
+ ot.MarkAsAlarmCondition(new AlarmConditionInfo(
+ "Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
+ return Task.CompletedTask;
+ }
+
+ public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
+
+ public Task SubscribeAlarmsAsync(
+ IReadOnlyList _, CancellationToken __)
+ => Task.FromResult(new FakeHandle("sub"));
+
+ public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
+ => Task.CompletedTask;
+
+ public Task AcknowledgeAsync(
+ IReadOnlyList _, CancellationToken __)
+ => Task.CompletedTask;
+ }
+
+ private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
+ {
+ public string DiagnosticId { get; } = diagnosticId;
+ }
+}