5fdf205caa
Gate: Primary-only typed recording with real status, raw+UNS HistoryRead/Processed parity, HistoryUpdate insert/duplicate/replace/delete + WriteOperate-only and anonymous denied, failover at 1x, Modbus device loss stored as BadCommunicationError then a BadNoCommunication outage marker. Gate found the CLI hid the node-level refusal code (fixed + pinned) and a pre-existing OpcUaClient comms-loss gap (filed separately). docker-dev compose gains ServerHistorian__Backend / __SqlServer__ConnectionString (default Gateway). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
226 lines
10 KiB
C#
226 lines
10 KiB
C#
using CliFx.Exceptions;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
|
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
|
|
|
public class HistoryUpdateCommandTests
|
|
{
|
|
private static HistoryUpdateCommand Make(FakeOpcUaClientService? svc = null, string mode = "insert",
|
|
string[]? values = null, string[]? times = null, string type = "double", string? start = null,
|
|
string? end = null, string node = "ns=2;s=HistNode")
|
|
=> new(new FakeOpcUaClientServiceFactory(svc ?? new FakeOpcUaClientService()))
|
|
{
|
|
Url = "opc.tcp://localhost:4840",
|
|
NodeId = node,
|
|
Mode = mode,
|
|
Values = values,
|
|
Times = times,
|
|
Type = type,
|
|
StartTime = start,
|
|
EndTime = end
|
|
};
|
|
|
|
/// <summary>Insert builds UpdateDataDetails with Insert.</summary>
|
|
[Fact]
|
|
public void Insert_BuildsUpdateDataDetails()
|
|
{
|
|
var d = Make(values: ["1.5", "2.5"], times: ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"])
|
|
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
|
d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
|
|
d.UpdateValues.Count.ShouldBe(2);
|
|
d.UpdateValues[1].Value.ShouldBe(2.5);
|
|
d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
|
|
d.NodeId.Identifier.ShouldBe("HistNode");
|
|
}
|
|
|
|
/// <summary>Replace and update map to their update types.</summary>
|
|
[Theory]
|
|
[InlineData("replace", PerformUpdateType.Replace)]
|
|
[InlineData("update", PerformUpdateType.Update)]
|
|
public void ReplaceAndUpdate_MapPerformType(string mode, PerformUpdateType expected)
|
|
{
|
|
var d = Make(mode: mode, values: ["1"], times: ["2026-01-15T08:00:00Z"])
|
|
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
|
d.PerformInsertReplace.ShouldBe(expected);
|
|
}
|
|
|
|
/// <summary>Each --type parses to the right CLR type.</summary>
|
|
[Theory]
|
|
[InlineData("double", "1.5", typeof(double))]
|
|
[InlineData("int64", "42", typeof(long))]
|
|
[InlineData("bool", "true", typeof(bool))]
|
|
[InlineData("string", "hello", typeof(string))]
|
|
[InlineData("datetime", "2026-01-15T08:00:00Z", typeof(DateTime))]
|
|
public void Type_ParsesValue(string type, string value, Type expected)
|
|
{
|
|
var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type)
|
|
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
|
d.UpdateValues[0].Value.ShouldBeOfType(expected);
|
|
}
|
|
|
|
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
|
|
[Fact]
|
|
public void DeleteRaw_BuildsDetails()
|
|
{
|
|
var d = Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z", end: "2026-01-15T09:00:00Z")
|
|
.BuildDetails().ShouldBeOfType<DeleteRawModifiedDetails>();
|
|
d.IsDeleteModified.ShouldBeFalse();
|
|
d.StartTime.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
|
|
d.EndTime.ShouldBe(new DateTime(2026, 1, 15, 9, 0, 0, DateTimeKind.Utc));
|
|
}
|
|
|
|
/// <summary>delete-at-time builds DeleteAtTimeDetails.</summary>
|
|
[Fact]
|
|
public void DeleteAtTime_BuildsDetails()
|
|
{
|
|
var d = Make(mode: "delete-at-time", times: ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"])
|
|
.BuildDetails().ShouldBeOfType<DeleteAtTimeDetails>();
|
|
d.ReqTimes.Count.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>Invalid combinations are rejected with a CommandException.</summary>
|
|
[Fact]
|
|
public void InvalidArguments_Throw()
|
|
{
|
|
var t = new[] { "2026-01-15T08:00:00Z" };
|
|
Should.Throw<CommandException>(() => Make(mode: "bogus").BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(values: ["1", "2"], times: t).BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(values: null, times: t).BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(values: ["abc"], times: t).BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(values: ["1"], times: ["notatime"]).BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(values: ["1"], times: t, type: "float").BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z").BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(mode: "delete-raw", start: "2026-01-15T09:00:00Z",
|
|
end: "2026-01-15T08:00:00Z").BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(mode: "delete-at-time").BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(mode: "delete-at-time", values: ["1"], times: t).BuildDetails());
|
|
Should.Throw<CommandException>(() => Make(node: "").BuildDetails());
|
|
}
|
|
|
|
/// <summary>Execute sends the details and prints one line per item with the status name.</summary>
|
|
[Fact]
|
|
public async Task Execute_PrintsPerItemStatus()
|
|
{
|
|
var svc = new FakeOpcUaClientService();
|
|
var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]);
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await cmd.ExecuteAsync(console);
|
|
svc.HistoryUpdateCalls.Count.ShouldBe(1);
|
|
var output = TestConsoleHelper.GetOutput(console);
|
|
output.ShouldContain("Good");
|
|
output.ShouldContain("Service result: Good");
|
|
}
|
|
|
|
/// <summary>A Bad item fails the command (non-zero exit) after printing.</summary>
|
|
[Fact]
|
|
public async Task Execute_BadItem_Throws()
|
|
{
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
|
[new List<StatusCode> { StatusCodes.BadEntryExists }])
|
|
};
|
|
var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]);
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
|
|
TestConsoleHelper.GetOutput(console).ShouldContain("BadEntryExists");
|
|
}
|
|
|
|
private static readonly string[] TwoTimes = ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"];
|
|
|
|
/// <summary>A Good service result that carries NO per-item results must not exit 0.</summary>
|
|
[Fact]
|
|
public async Task Execute_GoodServiceResult_WithMissingItemResults_Throws()
|
|
{
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], [new List<StatusCode>()])
|
|
};
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () =>
|
|
await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console));
|
|
TestConsoleHelper.GetOutput(console).ShouldContain("(no result returned)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node the server refuses outright carries its reason in the node-level result with no per-item
|
|
/// results; the CLI must print that reason, not just "(no result returned)" (found by the live gate).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Execute_NodeLevelDenial_PrintsNodeResult()
|
|
{
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.BadUserAccessDenied], [])
|
|
};
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () =>
|
|
await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console));
|
|
TestConsoleHelper.GetOutput(console).ShouldContain("Node result: BadUserAccessDenied");
|
|
}
|
|
|
|
/// <summary>Fewer per-item results than items sent must not exit 0, even when every returned one is Good.</summary>
|
|
[Fact]
|
|
public async Task Execute_TooFewItemResults_Throws()
|
|
{
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
|
[new List<StatusCode> { StatusCodes.GoodEntryInserted }])
|
|
};
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () =>
|
|
await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console));
|
|
}
|
|
|
|
/// <summary>A Good service result with no history-update result at all must not exit 0.</summary>
|
|
[Fact]
|
|
public async Task Execute_NoResultEntry_Throws()
|
|
{
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [], [])
|
|
};
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () =>
|
|
await Make(svc, mode: "delete-raw", start: TwoTimes[0], end: TwoTimes[1]).ExecuteAsync(console));
|
|
}
|
|
|
|
/// <summary>An Uncertain item result is not Good and fails the command.</summary>
|
|
[Fact]
|
|
public async Task Execute_UncertainItem_Throws()
|
|
{
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
|
[new List<StatusCode> { StatusCodes.UncertainDataSubNormal }])
|
|
};
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () =>
|
|
await Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]).ExecuteAsync(console));
|
|
TestConsoleHelper.GetOutput(console).ShouldContain("UncertainDataSubNormal");
|
|
}
|
|
|
|
/// <summary>A status the SDK has no name for prints as hex, never blank.</summary>
|
|
[Fact]
|
|
public async Task Execute_UnnamedStatus_PrintsHex()
|
|
{
|
|
const uint unnamed = 0x8FFF0000u;
|
|
StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
|
var svc = new FakeOpcUaClientService
|
|
{
|
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
|
[new List<StatusCode> { new(unnamed) }])
|
|
};
|
|
using var console = TestConsoleHelper.CreateConsole();
|
|
await Should.ThrowAsync<CommandException>(async () =>
|
|
await Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]).ExecuteAsync(console));
|
|
TestConsoleHelper.GetOutput(console).ShouldContain("0x8FFF0000");
|
|
}
|
|
}
|