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 }; /// Insert builds UpdateDataDetails with Insert. [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(); 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"); } /// Replace and update map to their update types. [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(); d.PerformInsertReplace.ShouldBe(expected); } /// Each --type parses to the right CLR type. [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(); d.UpdateValues[0].Value.ShouldBeOfType(expected); } /// delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false. [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(); 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)); } /// delete-at-time builds DeleteAtTimeDetails. [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(); d.ReqTimes.Count.ShouldBe(2); } /// Invalid combinations are rejected with a CommandException. [Fact] public void InvalidArguments_Throw() { var t = new[] { "2026-01-15T08:00:00Z" }; Should.Throw(() => Make(mode: "bogus").BuildDetails()); Should.Throw(() => Make(values: ["1", "2"], times: t).BuildDetails()); Should.Throw(() => Make(values: null, times: t).BuildDetails()); Should.Throw(() => Make(values: ["abc"], times: t).BuildDetails()); Should.Throw(() => Make(values: ["1"], times: ["notatime"]).BuildDetails()); Should.Throw(() => Make(values: ["1"], times: t, type: "float").BuildDetails()); Should.Throw(() => Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z").BuildDetails()); Should.Throw(() => Make(mode: "delete-raw", start: "2026-01-15T09:00:00Z", end: "2026-01-15T08:00:00Z").BuildDetails()); Should.Throw(() => Make(mode: "delete-at-time").BuildDetails()); Should.Throw(() => Make(mode: "delete-at-time", values: ["1"], times: t).BuildDetails()); Should.Throw(() => Make(node: "").BuildDetails()); } /// Execute sends the details and prints one line per item with the status name. [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"); } /// A Bad item fails the command (non-zero exit) after printing. [Fact] public async Task Execute_BadItem_Throws() { var svc = new FakeOpcUaClientService { HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], [new List { StatusCodes.BadEntryExists }]) }; var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]); using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(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"]; /// A Good service result that carries NO per-item results must not exit 0. [Fact] public async Task Execute_GoodServiceResult_WithMissingItemResults_Throws() { var svc = new FakeOpcUaClientService { HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], [new List()]) }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console)); TestConsoleHelper.GetOutput(console).ShouldContain("(no result returned)"); } /// /// 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). /// [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(async () => await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console)); TestConsoleHelper.GetOutput(console).ShouldContain("Node result: BadUserAccessDenied"); } /// Fewer per-item results than items sent must not exit 0, even when every returned one is Good. [Fact] public async Task Execute_TooFewItemResults_Throws() { var svc = new FakeOpcUaClientService { HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], [new List { StatusCodes.GoodEntryInserted }]) }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console)); } /// A Good service result with no history-update result at all must not exit 0. [Fact] public async Task Execute_NoResultEntry_Throws() { var svc = new FakeOpcUaClientService { HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [], []) }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await Make(svc, mode: "delete-raw", start: TwoTimes[0], end: TwoTimes[1]).ExecuteAsync(console)); } /// An Uncertain item result is not Good and fails the command. [Fact] public async Task Execute_UncertainItem_Throws() { var svc = new FakeOpcUaClientService { HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], [new List { StatusCodes.UncertainDataSubNormal }]) }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]).ExecuteAsync(console)); TestConsoleHelper.GetOutput(console).ShouldContain("UncertainDataSubNormal"); } /// A status the SDK has no name for prints as hex, never blank. [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 { new(unnamed) }]) }; using var console = TestConsoleHelper.CreateConsole(); await Should.ThrowAsync(async () => await Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]).ExecuteAsync(console)); TestConsoleHelper.GetOutput(console).ShouldContain("0x8FFF0000"); } }