using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit tests for the filter-aware /// /// overload (PR-12 / #284). The driver-level wire path needs a live /// so the round-trip-through-Session.HistoryReadAsync test lands as an integration test; /// here we cover the surface that's reachable without a session: SelectClause translation, /// default-clause fallback, and the EventFilter projection helper. /// [Trait("Category", "Unit")] public sealed class OpcUaClientHistoryEventsTests { [Fact] public void DefaultEventSelectClauses_carries_the_standard_BaseEventType_columns() { // The fallback set must match BuildHistoryEvent on the server side so a client that // doesn't customize the EventFilter still sees recognizable BaseEventType columns // (EventId, SourceName, Time, Message, Severity, ReceiveTime). var defaults = OpcUaClientDriver.DefaultEventSelectClauses; defaults.Count.ShouldBe(6); defaults.Select(d => d.FieldName).ShouldBe( ["EventId", "SourceName", "Time", "Message", "Severity", "ReceiveTime"]); // None of the defaults reach into a typed path — they're all rooted at BaseEventType // (TypeDefinitionId=null sentinel). defaults.All(d => d.TypeDefinitionId is null).ShouldBeTrue(); defaults.All(d => d.BrowsePath.Count == 1).ShouldBeTrue(); } [Fact] public void ToOpcEventFilter_translates_each_SimpleAttributeSpec_to_a_SimpleAttributeOperand() { var clauses = new List { new(null, ["EventId"], "EventId"), new(null, ["Severity"], "Severity"), new("i=2782" /* ConditionType */, [], "ConditionId"), }; var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, whereClause: null); filter.SelectClauses.Count.ShouldBe(3); filter.SelectClauses[0].TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType); filter.SelectClauses[0].BrowsePath.Count.ShouldBe(1); filter.SelectClauses[0].BrowsePath[0].Name.ShouldBe("EventId"); filter.SelectClauses[0].AttributeId.ShouldBe(Attributes.Value); filter.SelectClauses[1].BrowsePath[0].Name.ShouldBe("Severity"); // Typed-path entry: TypeDefinitionId parses to the supplied NodeId text and // BrowsePath stays empty (= "the typed node itself"). filter.SelectClauses[2].TypeDefinitionId.ShouldBe(NodeId.Parse("i=2782")); filter.SelectClauses[2].BrowsePath.Count.ShouldBe(0); } [Fact] public void ToOpcEventFilter_with_null_where_clause_leaves_WhereClause_empty() { // Empty WhereClause is the OPC UA equivalent of "no filter" — every event matches. // The client driver only attaches a WhereClause when one was decoded successfully; // a null/empty ContentFilterSpec should never produce an Elements collection. var clauses = new List { new(null, ["EventId"], "EventId"), }; var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, whereClause: null); filter.WhereClause.ShouldNotBeNull(); filter.WhereClause.Elements.Count.ShouldBe(0); } [Fact] public void ToOpcEventFilter_with_malformed_where_clause_bytes_swallows_and_yields_empty_filter() { // Defense-in-depth: a corrupt encoded filter must not throw out of the helper. // The driver chooses to drop the where-clause silently rather than fail the whole // HistoryReadEvents call (best-effort projection per IHistoryProvider contract). var clauses = new List { new(null, ["EventId"], "EventId"), }; var bogus = new ContentFilterSpec([0xFF, 0xFE, 0xFD]); // Provide a real MessageContext so the BinaryDecoder path is exercised; without it // the helper never attempts to decode and the test wouldn't cover the catch branch. #pragma warning disable CS0618 // ServiceMessageContext() — telemetry-context overload is irrelevant for unit decode. var ctx = new ServiceMessageContext(); #pragma warning restore CS0618 var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, bogus, ctx); filter.SelectClauses.Count.ShouldBe(1); // Either the decoder produced a default ContentFilter (Elements=0) or the catch // branch left the wire-default in place — either way no exception escaped. filter.WhereClause.ShouldNotBeNull(); } [Fact] public async Task ReadEventsAsync_filter_overload_without_initialize_throws_InvalidOperationException() { // Same uninitialized-driver guard the rest of the IHistoryProvider methods use. // Confirms the new overload is wired through RequireSession() rather than silently // returning an empty batch on a never-connected driver (which would mask wiring bugs). using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-evt-uninit"); var request = new EventHistoryRequest( StartTime: DateTime.UtcNow.AddMinutes(-5), EndTime: DateTime.UtcNow, NumValuesPerNode: 100, SelectClauses: null, WhereClause: null); await Should.ThrowAsync(async () => await drv.ReadEventsAsync( fullReference: "ns=2;s=AlarmsNotifier", request: request, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] public async Task ReadEventsAsync_filter_overload_rejects_null_request() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-evt-null"); await Should.ThrowAsync(async () => await drv.ReadEventsAsync( fullReference: "ns=2;s=AlarmsNotifier", request: null!, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] public async Task IHistoryProvider_filter_aware_default_throws_NotSupportedException_for_other_drivers() { // Other drivers that haven't opted in to the filter-aware overload must still see // the IHistoryProvider default — same shape as the parameterless overload's default. // We use a no-op stub to exercise the interface default's path. IHistoryProvider stub = new NotImplementedHistoryStub(); var request = new EventHistoryRequest( StartTime: DateTime.UtcNow.AddMinutes(-5), EndTime: DateTime.UtcNow, NumValuesPerNode: 100, SelectClauses: null, WhereClause: null); await Should.ThrowAsync(async () => await stub.ReadEventsAsync( fullReference: "ns=2;s=AlarmsNotifier", request: request, cancellationToken: TestContext.Current.CancellationToken)); } private sealed class NotImplementedHistoryStub : IHistoryProvider { public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotImplementedException(); } }