using System.Data; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; /// /// Walks over a real, seeded SQLite catalog through the /// — the only non-SQL-Server dialect in the tree, and therefore the control /// that proves the session runs the dialect's catalog SQL rather than INFORMATION_SCHEMA /// with a quoting helper bolted on. /// public sealed class SqlBrowseSessionTests { private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId; [Fact] public async Task RootAsync_yieldsSchemaFolders() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var roots = await session.RootAsync(CancellationToken.None); roots.Count.ShouldBe(1); roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema); roots[0].NodeId.ShouldBe(SchemaNodeId); roots[0].Kind.ShouldBe(BrowseNodeKind.Folder); roots[0].HasChildrenHint.ShouldBeTrue(); } [Fact] public async Task ExpandSchema_yieldsTablesAndViewsAsFolders() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint); children.ShouldContain(n => n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable)); // The view is listed beside the tables, and is labelled so the operator can tell them apart. var view = children .Where(n => n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName)) .ShouldHaveSingleItem(); view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName); view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName); } /// The plan's headline case: table expand yields columns as committable leaves. [Fact] public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); // Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --", // so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too. var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn && c.Kind == BrowseNodeKind.Leaf && !c.HasChildrenHint); var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn); var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None); var attribute = attributes.ShouldHaveSingleItem(); attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn); attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); attribute.IsArray.ShouldBeFalse(); attribute.SecurityClass.ShouldBe("ViewOnly"); attribute.IsAlarm.ShouldBeFalse(); } [Fact] public async Task AttributesAsync_mapsTextColumnToString() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn); var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// An exotic declared type must fall back to via /// MapColumnType rather than crash the browse — a table with one geography column must /// still render. /// [Fact] public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable); var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None); columns.Count.ShouldBe(2); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn); var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// The end-to-end awkward-identifier walk: a table named a.b|c holding a column named /// x|y. Both characters are the ones a naive schema.table|column NodeId splits on, so /// this is the case that proves the encoding, not just the codec unit test. /// [Fact] public async Task AwkwardIdentifiers_surviveTheFullWalk() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); var awkward = tables .Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable) .ShouldHaveSingleItem(); var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None); columns.Select(c => c.DisplayName) .ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 }, ignoreOrder: true); var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn); var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId); parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable); parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn); var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); } /// /// A table whose name is an injection payload. If any catalog query concatenated the name /// instead of binding @table, expanding it would drop TagValues. /// [Fact] public async Task HostileTableName_isBound_notConcatenated() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable); var columns = await session.ExpandAsync(nodeId, CancellationToken.None); // Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it // before anything about the columns, or a splice that returns no rows reds on the wrong line and the // test stops proving the payload was inert. db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn); var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None); db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32)); } /// A hostile schema name takes the same bound path at the table level. [Fact] public async Task HostileSchemaName_isBound_notConcatenated() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --"); var tables = await session.ExpandAsync(nodeId, CancellationToken.None); // SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing. tables.ShouldBeEmpty(); db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); } /// /// A table node whose table is gone (dropped between browse and expand) yields an empty column list. /// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less /// table, and an empty catalog answer is what both shapes look like from the browser's side. /// [Fact] public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable); (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever"); (await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty(); } /// A column NodeId is terminal — expanding it is a no-op, never an error. [Fact] public async Task ExpandColumn_yieldsEmpty() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); } /// Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser. [Fact] public async Task AttributesAsync_onNonLeafNode_yieldsEmpty() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); (await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty(); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); (await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty(); } /// /// A garbage NodeId must surface as an carrying an operator-readable /// message — the same contract the Galaxy and OPC UA client sessions have, and what /// DriverBrowseTree.razor renders inline as the node's error. It must not be a /// or an from a blind split. /// [Theory] [InlineData("")] [InlineData("not-a-node-id")] [InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format [InlineData("column:a|b|c|d")] public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId) { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var expand = await Should.ThrowAsync( () => session.ExpandAsync(nodeId, CancellationToken.None)); expand.Message.ShouldNotBeNullOrWhiteSpace(); await Should.ThrowAsync( () => session.AttributesAsync(nodeId, CancellationToken.None)); } /// /// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate. /// Asserted by measuring overlap directly through : a results-only /// assertion would pass with the gate deleted. /// [Fact] public async Task ConcurrentCalls_areSerializedByTheGate() { await using var db = await SqliteBrowseFixture.CreateAsync(); var probe = new ConcurrencyProbe(); var probing = new ConcurrencyProbingDbConnection(db.Connection, probe); await using var session = new SqlBrowseSession(probing, new SqliteDialect()); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); var calls = new[] { session.RootAsync(CancellationToken.None), session.ExpandAsync(SchemaNodeId, CancellationToken.None), session.ExpandAsync(tableNodeId, CancellationToken.None), session.ExpandAsync(tableNodeId, CancellationToken.None), }; var results = await Task.WhenAll(calls); probe.Peak.ShouldBe(1); // TagValues has three columns — proves the serialized calls still returned real catalog rows. results[2].Count.ShouldBe(3); } /// /// Ownership: the session takes over the connection it is handed and closes it on dispose. The /// AdminUI's BrowseSessionRegistry is the only thing holding a session, so if the session did /// not close the connection, nothing would — every reaped picker would leak one. /// [Fact] public async Task DisposeAsync_closesTheConnectionItWasGiven() { await using var db = await SqliteBrowseFixture.CreateAsync(); var connection = db.OpenNewConnection(); var session = new SqlBrowseSession(connection, new SqliteDialect()); connection.State.ShouldBe(ConnectionState.Open); await session.DisposeAsync(); connection.State.ShouldBe(ConnectionState.Closed); } [Fact] public async Task DisposeAsync_isIdempotent() { await using var db = await SqliteBrowseFixture.CreateAsync(); var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); await session.DisposeAsync(); await Should.NotThrowAsync(async () => await session.DisposeAsync()); } [Fact] public async Task CallsAfterDispose_throwObjectDisposedException() { await using var db = await SqliteBrowseFixture.CreateAsync(); var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); await session.DisposeAsync(); await Should.ThrowAsync( () => session.RootAsync(CancellationToken.None)); await Should.ThrowAsync( () => session.ExpandAsync(SchemaNodeId, CancellationToken.None)); } /// /// The AdminUI bounds every call with a 20 s linked CTS /// (BrowserSessionService.PerCallTimeout) — the session invents no timeout of its own, it just /// has to honour the token it is handed, all the way into the ADO.NET call. /// [Fact] public async Task CancelledToken_isHonoured() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync(() => session.RootAsync(cts.Token)); await Should.ThrowAsync(() => session.ExpandAsync(SchemaNodeId, cts.Token)); } /// /// feeds the registry's idle reaper — a session that never /// refreshed it would be evicted out from under an actively browsing operator. /// [Fact] public async Task LastUsedUtc_advancesOnEverySuccessfulCall() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var atStart = session.LastUsedUtc; await Task.Delay(15, TestContext.Current.CancellationToken); await session.RootAsync(CancellationToken.None); var afterRoot = session.LastUsedUtc; afterRoot.ShouldBeGreaterThan(atStart); await Task.Delay(15, TestContext.Current.CancellationToken); await session.ExpandAsync(SchemaNodeId, CancellationToken.None); var afterExpand = session.LastUsedUtc; afterExpand.ShouldBeGreaterThan(afterRoot); await Task.Delay(15, TestContext.Current.CancellationToken); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); await session.AttributesAsync(columnNodeId, CancellationToken.None); session.LastUsedUtc.ShouldBeGreaterThan(afterExpand); } /// /// The browse path binds the dialect's parameter names, not a hardcoded "@schema". /// Driven through (markers :schema/:table, bare bind /// names) — ODP.NET's shape, and the only place the two spellings differ. /// Discriminating in two independent ways: a session still binding "@schema" leaves the /// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the /// recorded names would carry the prefix. /// [Fact] public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames() { await using var db = await SqliteBrowseFixture.CreateAsync(); var dialect = new ColonMarkerDialect(); await using var session = new SqlBrowseSession(db.Connection, dialect); var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn); dialect.BoundParameterNames.ShouldContain("schema"); dialect.BoundParameterNames.ShouldContain("table"); dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':')); // Every catalog command went through the provider hook (Oracle's BindByName lives there). dialect.ConfiguredCommands.ShouldBe(2); } // ---- the wide-row ROW SELECTOR candidate (Gitea #552) ----------------------------------------- /// /// The session states the three parts of a Sql binding it can know — the table, the column, and the /// table's own date/time column, which the commit turns into rowSelector.topByTimestamp. /// Without the third the committed blob is a wide-row tag that never says which ROW it reads, which /// the driver's parser and the authoring gate both refuse. /// [Fact] public async Task AttributesAsync_statesTheTablesSoleDateTimeColumn_asTheRowSelectorCandidate() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.TimestampedTable, SqliteBrowseFixture.TimestampedTableValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.Schema].ShouldBe(SqliteDialect.MainSchema); fields[SqlBrowseAddressFields.Table].ShouldBe(SqliteBrowseFixture.TimestampedTable); fields[SqlBrowseAddressFields.ColumnName].ShouldBe(SqliteBrowseFixture.TimestampedTableValueColumn); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn); } /// /// The tie-break is FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position), /// so two operators browsing the same table commit the same binding. /// The fixture's two candidates are named so catalog order and alphabetical order DISAGREE — /// zulu_time is declared first and sorts last. A tie-break that quietly became "any candidate" /// or "sorted by name" fails here rather than passing by luck. /// [Fact] public async Task AttributesAsync_breaksAMultiCandidateTie_onCatalogOrderNotName() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.MultiTimeTable, SqliteBrowseFixture.MultiTimeValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe( SqliteBrowseFixture.MultiTimeFirstColumn, "the first date/time column in catalog order wins, not the first alphabetically"); // Guards the fixture itself: the assertion above is only a tie-break test while the two disagree. string.CompareOrdinal( SqliteBrowseFixture.MultiTimeFirstColumn, SqliteBrowseFixture.MultiTimeSecondColumn) .ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one"); } /// /// A table with no date/time column states NO candidate — the key is absent, never blank — so the /// commit can tell "this table cannot be bound by browse" from "the session said nothing". /// The control that makes this sharp: TagValues holds sample_ts, a column that /// LOOKS like a timestamp and is declared TEXT. Candidacy is decided by the mapped type, so a /// name heuristic — which would order the wide-row read by a string column, binding the tag to /// whichever row sorts last lexically — fails this test. /// [Fact] public async Task AttributesAsync_statesNoCandidate_whenNoColumnIsDateTimeTyped() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields.ShouldContainKey(SqlBrowseAddressFields.Table); fields.ShouldNotContainKey(SqlBrowseAddressFields.TopByTimestamp); // The name-heuristic control, stated explicitly: the tempting column IS present and IS a string. var stringTyped = await session.AttributesAsync( SqlBrowseNodeId.ForColumn(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, "sample_ts"), CancellationToken.None); stringTyped.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// The candidate costs no extra catalog round-trip: it is computed from the columns the attributes /// call already read. Counting the dialect's configured commands is the cheapest honest proxy — /// one ListColumnsSql execution, exactly as before #552. /// [Fact] public async Task AttributesAsync_computesTheCandidate_withoutASecondCatalogQuery() { await using var db = await SqliteBrowseFixture.CreateAsync(); var dialect = new ColonMarkerDialect(); await using var session = new SqlBrowseSession(db.Connection, dialect); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.TimestampedTable, SqliteBrowseFixture.TimestampedTableValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn); dialect.ConfiguredCommands.ShouldBe(1); } }