feat(worker): route alarm acks through the operator-authenticated AckMsg writer
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler();
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
Guid g = Guid.NewGuid();
|
||||
|
||||
@@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests
|
||||
Assert.NotNull(reply.AcknowledgeAlarm);
|
||||
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
|
||||
Assert.Equal(g, handler.LastAckGuid);
|
||||
Assert.Equal("alice", handler.LastAckOperatorName);
|
||||
Assert.Equal("ack", handler.LastComment);
|
||||
Assert.Equal("alice", handler.LastOperator!.UserName);
|
||||
Assert.Equal("CORP", handler.LastOperator.Domain);
|
||||
Assert.Equal(string.Empty, handler.LastOperator.Password);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
|
||||
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
|
||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary>
|
||||
/// <summary>Verifies that a rejected write carries its native status and diagnostic.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 };
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."),
|
||||
};
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
|
||||
StaCommand command = new StaCommand(
|
||||
@@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests
|
||||
|
||||
MxCommandReply reply = executor.Execute(command);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(-123, reply.Hresult);
|
||||
Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus);
|
||||
Assert.Contains("-123", reply.DiagnosticMessage);
|
||||
}
|
||||
|
||||
@@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
[Fact]
|
||||
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler();
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
|
||||
StaCommand command = new StaCommand(
|
||||
@@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests
|
||||
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
|
||||
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
|
||||
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
|
||||
Assert.Equal("alice", handler.LastAckOperatorName);
|
||||
Assert.Equal("ack", handler.LastComment);
|
||||
Assert.Equal("alice", handler.LastOperator!.UserName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every ack outcome maps to its protocol status, carries the native
|
||||
/// status, and never echoes the operator password.
|
||||
/// </summary>
|
||||
/// <param name="outcome">The writer outcome.</param>
|
||||
/// <param name="expected">The expected protocol status.</param>
|
||||
[Theory]
|
||||
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
|
||||
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
|
||||
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
|
||||
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
|
||||
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
|
||||
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
|
||||
public void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
|
||||
{
|
||||
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
|
||||
};
|
||||
|
||||
MxCommandReply reply = Execute(handler, new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
||||
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
||||
{
|
||||
AlarmGuid = Guid.NewGuid().ToString(),
|
||||
Comment = "checked",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "pw-secret",
|
||||
},
|
||||
});
|
||||
|
||||
AssertAckReply(reply, handler, expected, native);
|
||||
}
|
||||
|
||||
/// <summary>The by-name ack maps outcomes identically and never echoes the operator password.</summary>
|
||||
/// <param name="outcome">The writer outcome.</param>
|
||||
/// <param name="expected">The expected protocol status.</param>
|
||||
[Theory]
|
||||
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
|
||||
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
|
||||
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
|
||||
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
|
||||
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
|
||||
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
|
||||
public void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
|
||||
{
|
||||
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
|
||||
};
|
||||
|
||||
MxCommandReply reply = Execute(handler, new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
||||
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
||||
{
|
||||
AlarmName = "TestMachine_001.TestAlarm001",
|
||||
ProviderName = "Galaxy",
|
||||
GroupName = "TestArea",
|
||||
Comment = "checked",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "pw-secret",
|
||||
},
|
||||
});
|
||||
|
||||
AssertAckReply(reply, handler, expected, native);
|
||||
}
|
||||
|
||||
/// <summary>The writer's OnWriteComplete statuses are copied onto the reply.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply()
|
||||
{
|
||||
MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 };
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }),
|
||||
};
|
||||
|
||||
MxCommandReply reply = Execute(handler, new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
||||
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" },
|
||||
});
|
||||
|
||||
Assert.Equal(status, Assert.Single(reply.Statuses));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An exception escaping the ack path (e.g. the writer's MXAccess
|
||||
/// Register failing) maps to MxaccessFailure with only the exception type
|
||||
/// and HRESULT — never the exception text, which is not under our control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)),
|
||||
};
|
||||
|
||||
MxCommandReply reply = Execute(handler, new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
||||
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
||||
{
|
||||
AlarmName = "TestMachine_001.TestAlarm001",
|
||||
ProviderName = "Galaxy",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(unchecked((int)0x80004005), reply.Hresult);
|
||||
Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal);
|
||||
Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
|
||||
@@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true };
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"),
|
||||
};
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
|
||||
StaCommand command = new StaCommand(
|
||||
@@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests
|
||||
MxCommandReply reply = executor.Execute(command);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||
Assert.Contains("simulated", reply.DiagnosticMessage);
|
||||
Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) =>
|
||||
NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command));
|
||||
|
||||
private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native)
|
||||
{
|
||||
Assert.Equal(expected, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(native, reply.Hresult);
|
||||
Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
|
||||
Assert.Equal("diagnostic for alice", reply.DiagnosticMessage);
|
||||
Assert.Equal("checked", handler.LastComment);
|
||||
Assert.Equal("alice", handler.LastOperator!.UserName);
|
||||
Assert.Equal("CORP", handler.LastOperator.Domain);
|
||||
Assert.Equal("pw-secret", handler.LastOperator.Password);
|
||||
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
|
||||
@@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests
|
||||
/// <summary>Gets the last acknowledge alarm GUID.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
|
||||
/// <summary>Gets the last acknowledge operator name.</summary>
|
||||
public string? LastAckOperatorName { get; private set; }
|
||||
/// <summary>Gets the last acknowledge comment.</summary>
|
||||
public string? LastComment { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the value returned by acknowledge.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets the last acknowledge operator.</summary>
|
||||
public AlarmAckOperator? LastOperator { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
|
||||
public bool AcknowledgeThrow { get; set; }
|
||||
/// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
|
||||
public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||
|
||||
/// <summary>Gets or sets an exception both acknowledge methods throw.</summary>
|
||||
public Exception? AcknowledgeException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the query result snapshots.</summary>
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
|
||||
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid, string comment, string operatorUser,
|
||||
string operatorNode, string operatorDomain, string operatorFullName)
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
LastAckGuid = alarmGuid;
|
||||
LastAckOperatorName = operatorUser;
|
||||
if (AcknowledgeThrow)
|
||||
LastComment = comment;
|
||||
LastOperator = ackOperator;
|
||||
if (AcknowledgeException is not null)
|
||||
{
|
||||
throw new InvalidOperationException("simulated alarm-handler failure");
|
||||
throw AcknowledgeException;
|
||||
}
|
||||
return AcknowledgeReturn;
|
||||
|
||||
return AckResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
string alarmName, string providerName, string groupName,
|
||||
string comment, string operatorUser, string operatorNode,
|
||||
string operatorDomain, string operatorFullName)
|
||||
public AlarmAckResult AcknowledgeByName(
|
||||
string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
LastAckByNameTuple = (alarmName, providerName, groupName);
|
||||
LastAckOperatorName = operatorUser;
|
||||
return AcknowledgeReturn;
|
||||
LastComment = comment;
|
||||
LastOperator = ackOperator;
|
||||
if (AcknowledgeException is not null)
|
||||
{
|
||||
throw AcknowledgeException;
|
||||
}
|
||||
|
||||
return AckResult;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last acknowledge by name tuple.</summary>
|
||||
|
||||
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
|
||||
Assert.False(handler.IsSubscribed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
|
||||
/// <summary>Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
|
||||
public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => consumer);
|
||||
FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
|
||||
RecordingAckWriter writer = new RecordingAckWriter();
|
||||
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
Guid g = Guid.NewGuid();
|
||||
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");
|
||||
AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" };
|
||||
AlarmAckResult result = handler.Acknowledge(g, "c", op);
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.Equal(g, consumer.LastAckGuid);
|
||||
Assert.Equal("u", consumer.LastAckOperatorName);
|
||||
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
||||
Assert.Equal(g, consumer.LastResolvedGuid);
|
||||
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
|
||||
Assert.Equal("c", writer.LastComment);
|
||||
Assert.Same(op, writer.LastOperator);
|
||||
}
|
||||
|
||||
/// <summary>Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer();
|
||||
RecordingAckWriter writer = new RecordingAckWriter();
|
||||
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" });
|
||||
|
||||
Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName);
|
||||
Assert.Equal(string.Empty, writer.LastComment);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a null operator is rejected.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_NullOperator_Throws()
|
||||
{
|
||||
AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ack writer is created once, on first acknowledgement, survives an
|
||||
/// unsubscribe/resubscribe cycle, and is disposed with the handler.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler()
|
||||
{
|
||||
RecordingAckWriter writer = new RecordingAckWriter();
|
||||
int created = 0;
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => new FakeConsumer(),
|
||||
threadAffinityCheck: null,
|
||||
comFactory: null,
|
||||
standbyFactory: null,
|
||||
ackWriterFactory: () =>
|
||||
{
|
||||
created++;
|
||||
return writer;
|
||||
});
|
||||
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
Assert.Equal(0, created);
|
||||
|
||||
handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator());
|
||||
handler.Unsubscribe();
|
||||
Assert.False(writer.Disposed);
|
||||
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator());
|
||||
Assert.Equal(1, created);
|
||||
|
||||
handler.Dispose();
|
||||
Assert.True(writer.Disposed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without a COM factory or writer factory, subscribing still works and
|
||||
/// the acknowledgement fails with a diagnosable InvalidOperationException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation()
|
||||
{
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer());
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
||||
() => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
|
||||
Assert.Contains("IMxAccessComObjectFactory", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
|
||||
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
() => new FakeConsumer());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
|
||||
@@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => consumer,
|
||||
() => guardInvocations++);
|
||||
() => guardInvocations++,
|
||||
comFactory: null,
|
||||
standbyFactory: null,
|
||||
ackWriterFactory: () => new RecordingAckWriter());
|
||||
|
||||
// Subscribe is the first call — guard must run before the consumer
|
||||
// factory is invoked. We tally invocation counts after each call so
|
||||
@@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
Assert.Equal(1, guardInvocations);
|
||||
|
||||
handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F");
|
||||
handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" });
|
||||
Assert.Equal(2, guardInvocations);
|
||||
|
||||
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
|
||||
handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" });
|
||||
Assert.Equal(3, guardInvocations);
|
||||
|
||||
_ = handler.QueryActive(null, out _);
|
||||
@@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests
|
||||
// assert each independently with a fresh handler. Below we reuse
|
||||
// the same throwing handler for the not-subscribed-yet entries:
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
|
||||
() => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
@@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests
|
||||
[Fact]
|
||||
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
|
||||
{
|
||||
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
|
||||
FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
||||
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
|
||||
FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
|
||||
FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
|
||||
FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
|
||||
int buildCount = 0;
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => primary,
|
||||
threadAffinityCheck: null,
|
||||
comFactory: null,
|
||||
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement);
|
||||
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement,
|
||||
ackWriterFactory: () => new RecordingAckWriter());
|
||||
|
||||
handler.Subscribe(new SubscribeAlarmsCommand
|
||||
{
|
||||
@@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
Assert.Equal(2, buildCount);
|
||||
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
|
||||
Assert.True(initialStandby.Disposed);
|
||||
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
Assert.Equal("11.AckMsg", AckItemOf(handler));
|
||||
}
|
||||
|
||||
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
|
||||
@@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests
|
||||
[Fact]
|
||||
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
|
||||
{
|
||||
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
|
||||
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
||||
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 };
|
||||
FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
|
||||
FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
|
||||
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") };
|
||||
int buildCount = 0;
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => primary,
|
||||
null,
|
||||
null,
|
||||
_ => ++buildCount == 1 ? oldStandby : failedReplacement);
|
||||
_ => ++buildCount == 1 ? oldStandby : failedReplacement,
|
||||
() => new RecordingAckWriter());
|
||||
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
||||
{
|
||||
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
||||
@@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests
|
||||
|
||||
Assert.False(oldStandby.Disposed);
|
||||
Assert.True(failedReplacement.Disposed);
|
||||
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
Assert.Equal("11.AckMsg", AckItemOf(handler));
|
||||
}
|
||||
|
||||
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
|
||||
[Fact]
|
||||
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
|
||||
{
|
||||
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 };
|
||||
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
||||
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
|
||||
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") };
|
||||
FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
|
||||
FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
|
||||
int buildCount = 0;
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => primary,
|
||||
null,
|
||||
null,
|
||||
_ => ++buildCount == 1 ? oldStandby : replacement);
|
||||
_ => ++buildCount == 1 ? oldStandby : replacement,
|
||||
() => new RecordingAckWriter());
|
||||
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
||||
{
|
||||
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
||||
@@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
|
||||
|
||||
Assert.True(oldStandby.Disposed);
|
||||
Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
Assert.Equal("33.AckMsg", AckItemOf(handler));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
|
||||
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
|
||||
}
|
||||
|
||||
private static AlarmCommandHandler NewHandlerWithWriter(Func<IMxAccessAlarmConsumer> consumerFactory, IAlarmAckWriter writer) =>
|
||||
new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
consumerFactory,
|
||||
threadAffinityCheck: null,
|
||||
comFactory: null,
|
||||
standbyFactory: null,
|
||||
ackWriterFactory: () => writer);
|
||||
|
||||
/// <summary>Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).</summary>
|
||||
private static string AckItemOf(AlarmCommandHandler handler) =>
|
||||
handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic;
|
||||
|
||||
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
|
||||
{
|
||||
return new MxAlarmSnapshotRecord
|
||||
@@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests
|
||||
|
||||
/// <summary>Gets the last subscription request.</summary>
|
||||
public string? LastSubscription { get; private set; }
|
||||
/// <summary>Gets the last acknowledged alarm GUID.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
/// <summary>Gets the last acknowledged operator name.</summary>
|
||||
public string? LastAckOperatorName { get; private set; }
|
||||
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
|
||||
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
|
||||
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
|
||||
public Guid LastResolvedGuid { get; private set; }
|
||||
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
|
||||
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
|
||||
/// <summary>Gets or sets the snapshot result to return.</summary>
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
||||
Array.Empty<MxAlarmSnapshotRecord>();
|
||||
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid, string ackComment, string ackOperatorName,
|
||||
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||
{
|
||||
LastAckGuid = alarmGuid;
|
||||
LastAckOperatorName = ackOperatorName;
|
||||
return AcknowledgeReturn;
|
||||
LastResolvedGuid = alarmGuid;
|
||||
return AckTarget;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
string alarmName, string providerName, string groupName,
|
||||
string ackComment, string ackOperatorName, string ackOperatorNode,
|
||||
string ackOperatorDomain, string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
||||
{
|
||||
LastAckByNameTuple = (alarmName, providerName, groupName);
|
||||
LastAckOperatorName = ackOperatorName;
|
||||
return AcknowledgeReturn;
|
||||
LastResolvedName = (alarmName, providerName, groupName);
|
||||
return AckTarget;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
@@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests
|
||||
Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAckWriter : IAlarmAckWriter
|
||||
{
|
||||
public string? LastItemAddress { get; private set; }
|
||||
public string? LastComment { get; private set; }
|
||||
public AlarmAckOperator? LastOperator { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
LastItemAddress = itemAddress;
|
||||
LastComment = comment;
|
||||
LastOperator = ackOperator;
|
||||
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class AlarmDispatcherTests
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
|
||||
|
||||
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||
{
|
||||
@@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
|
||||
|
||||
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
|
||||
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||
@@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
|
||||
|
||||
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||
{
|
||||
@@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
|
||||
|
||||
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||
{
|
||||
@@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
||||
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary>
|
||||
/// <summary>Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
|
||||
public void Acknowledge_ResolvesTargetThenWritesWithOperator()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
consumer.AcknowledgeReturn = 0;
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
|
||||
FakeAckWriter writer = new FakeAckWriter();
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
|
||||
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||
Guid guid = Guid.NewGuid();
|
||||
int rc = dispatcher.Acknowledge(
|
||||
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
||||
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.Equal(guid, consumer.LastAckGuid);
|
||||
Assert.Equal("Acked", consumer.LastAckComment);
|
||||
Assert.Equal("alice", consumer.LastAckOperatorName);
|
||||
Assert.Equal("WS01", consumer.LastAckOperatorNode);
|
||||
Assert.Equal("CORP", consumer.LastAckOperatorDomain);
|
||||
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
|
||||
AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
||||
Assert.Equal(guid, consumer.LastResolvedGuid);
|
||||
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
|
||||
Assert.Equal("checked", writer.LastComment);
|
||||
Assert.Same(op, writer.LastOperator);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a managed ack client takes GUID acks with full operator identity.</summary>
|
||||
/// <summary>Verifies an unresolved target short-circuits to its failure without calling the writer.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient()
|
||||
public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 };
|
||||
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 };
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
|
||||
FakeAckWriter writer = new FakeAckWriter();
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId,
|
||||
managed);
|
||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
||||
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||
|
||||
Guid guid = Guid.NewGuid();
|
||||
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
||||
AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription);
|
||||
Assert.Equal(guid, managed.LastAckGuid);
|
||||
Assert.Equal("CORP", managed.LastAckOperatorDomain);
|
||||
Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
|
||||
Assert.Equal(Guid.Empty, consumer.LastAckGuid);
|
||||
Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
|
||||
Assert.Equal("gone", result.Diagnostic);
|
||||
Assert.Null(writer.LastItemAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a failed managed-client lifecycle neither fails the alarm
|
||||
/// subscription nor leaves acks routed to the broken client.
|
||||
/// </summary>
|
||||
/// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
|
||||
[Fact]
|
||||
public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck()
|
||||
public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
|
||||
FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true };
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer
|
||||
{
|
||||
AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"),
|
||||
};
|
||||
FakeAckWriter writer = new FakeAckWriter();
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId,
|
||||
managed);
|
||||
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" };
|
||||
|
||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
||||
Guid guid = Guid.NewGuid();
|
||||
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
||||
AlarmAckResult result = dispatcher.AcknowledgeByName(
|
||||
"TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op);
|
||||
|
||||
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
|
||||
Assert.True(managed.Disposed);
|
||||
Assert.Equal(0, rc);
|
||||
Assert.Equal(guid, consumer.LastAckGuid);
|
||||
Assert.Equal(Guid.Empty, managed.LastAckGuid);
|
||||
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
||||
Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName);
|
||||
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress);
|
||||
Assert.Equal("ack", writer.LastComment);
|
||||
Assert.Same(op, writer.LastOperator);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a NotAckable by-name target short-circuits without calling the writer.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByName_WhenNotAckable_DoesNotWrite()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") };
|
||||
FakeAckWriter writer = new FakeAckWriter();
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||
|
||||
AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome);
|
||||
Assert.Null(writer.LastItemAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that while the failover consumer serves the subtag standby,
|
||||
/// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client.
|
||||
/// GUID acks resolve through the standby (its synthetic GUIDs).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer()
|
||||
public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby()
|
||||
{
|
||||
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
|
||||
FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
|
||||
FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") };
|
||||
FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
|
||||
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
|
||||
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 };
|
||||
FakeAckWriter writer = new FakeAckWriter();
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
failover,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId,
|
||||
managed);
|
||||
writer);
|
||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
||||
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
|
||||
|
||||
Guid guid = Guid.NewGuid();
|
||||
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
||||
dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" });
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.Equal(guid, standby.LastAckGuid);
|
||||
Assert.Equal(Guid.Empty, managed.LastAckGuid);
|
||||
Assert.Equal(guid, standby.LastResolvedGuid);
|
||||
Assert.Equal(Guid.Empty, primary.LastResolvedGuid);
|
||||
Assert.Equal("Standby.AckMsg", writer.LastItemAddress);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the dispatcher disposes the managed ack client.</summary>
|
||||
/// <summary>Verifies the dispatcher does not dispose the writer: the handler owns it.</summary>
|
||||
[Fact]
|
||||
public void Dispose_WithManagedAckClient_DisposesManagedClient()
|
||||
public void Dispose_DoesNotDisposeAckWriter()
|
||||
{
|
||||
FakeManagedAckClient managed = new FakeManagedAckClient();
|
||||
FakeAckWriter writer = new FakeAckWriter();
|
||||
AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
new FakeAlarmConsumer(),
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId,
|
||||
managed);
|
||||
writer);
|
||||
|
||||
dispatcher.Dispose();
|
||||
|
||||
Assert.True(managed.Disposed);
|
||||
Assert.False(writer.Disposed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
|
||||
/// <summary>Verifies the constructor rejects a null ack writer.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
|
||||
public void Constructor_NullAckWriter_Throws()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
Assert.Throws<ArgumentNullException>(() => new AlarmDispatcher(
|
||||
new FakeAlarmConsumer(),
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
|
||||
int rc = dispatcher.AcknowledgeByName(
|
||||
alarmName: "TestMachine_001.TestAlarm001",
|
||||
providerName: "Galaxy",
|
||||
groupName: "TestArea",
|
||||
ackComment: "ack",
|
||||
ackOperatorName: "alice",
|
||||
ackOperatorNode: "WS",
|
||||
ackOperatorDomain: "CORP",
|
||||
ackOperatorFullName: "Alice Smith");
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.NotNull(consumer.LastAckByNameTuple);
|
||||
Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name);
|
||||
Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider);
|
||||
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
|
||||
SessionId,
|
||||
null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
|
||||
@@ -391,7 +376,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
@@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
@@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
@@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
@@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
||||
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
|
||||
|
||||
dispatcher.Dispose();
|
||||
|
||||
@@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests
|
||||
|
||||
/// <summary>Gets the last subscription reference.</summary>
|
||||
public string? LastSubscription { get; private set; }
|
||||
/// <summary>Gets the GUID from the last acknowledge call.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
/// <summary>Gets the comment from the last acknowledge call.</summary>
|
||||
public string? LastAckComment { get; private set; }
|
||||
/// <summary>Gets the operator name from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorName { get; private set; }
|
||||
/// <summary>Gets the operator node from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorNode { get; private set; }
|
||||
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorDomain { get; private set; }
|
||||
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorFullName { get; private set; }
|
||||
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
|
||||
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
|
||||
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
|
||||
public Guid LastResolvedGuid { get; private set; }
|
||||
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
|
||||
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
|
||||
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
||||
Array.Empty<MxAlarmSnapshotRecord>();
|
||||
@@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||
{
|
||||
LastAckGuid = alarmGuid;
|
||||
LastAckComment = ackComment;
|
||||
LastAckOperatorName = ackOperatorName;
|
||||
LastAckOperatorNode = ackOperatorNode;
|
||||
LastAckOperatorDomain = ackOperatorDomain;
|
||||
LastAckOperatorFullName = ackOperatorFullName;
|
||||
return AcknowledgeReturn;
|
||||
LastResolvedGuid = alarmGuid;
|
||||
return AckTarget;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
string alarmName, string providerName, string groupName,
|
||||
string ackComment, string ackOperatorName, string ackOperatorNode,
|
||||
string ackOperatorDomain, string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
||||
{
|
||||
LastAckByNameTuple = (alarmName, providerName, groupName);
|
||||
LastAckOperatorName = ackOperatorName;
|
||||
return AcknowledgeReturn;
|
||||
LastResolvedName = (alarmName, providerName, groupName);
|
||||
return AckTarget;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
@@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeManagedAckClient : IManagedAlarmAckClient
|
||||
private sealed class FakeAckWriter : IAlarmAckWriter
|
||||
{
|
||||
/// <summary>Gets the last subscription expression.</summary>
|
||||
public string? LastSubscription { get; private set; }
|
||||
/// <summary>Gets the GUID from the last acknowledge call.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorDomain { get; private set; }
|
||||
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorFullName { get; private set; }
|
||||
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
|
||||
public bool ThrowOnSubscribe { get; set; }
|
||||
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
|
||||
public string? LastItemAddress { get; private set; }
|
||||
public string? LastComment { get; private set; }
|
||||
public AlarmAckOperator? LastOperator { get; private set; }
|
||||
public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(string subscription)
|
||||
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5.");
|
||||
LastSubscription = subscription;
|
||||
LastItemAddress = itemAddress;
|
||||
LastComment = comment;
|
||||
LastOperator = ackOperator;
|
||||
return Result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorName,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
{
|
||||
LastAckGuid = alarmGuid;
|
||||
LastAckOperatorDomain = operatorDomain;
|
||||
LastAckOperatorFullName = operatorFullName;
|
||||
return AcknowledgeReturn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
}
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
|
||||
string operatorNode, string operatorDomain, string operatorFullName)
|
||||
=> 0;
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||
=> AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
|
||||
string comment, string operatorUser, string operatorNode,
|
||||
string operatorDomain, string operatorFullName)
|
||||
=> 0;
|
||||
public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName,
|
||||
string comment, AlarmAckOperator ackOperator)
|
||||
=> AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
|
||||
|
||||
Reference in New Issue
Block a user