using System.Data.Common; using System.Globalization; using System.Text.Json.Nodes; 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.Browser; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; /// /// The Sql driver against a real PostgreSQL, over the Npgsql provider /// PostgresDialect ships. The offline suites all run on SQLite, so three things are exercised /// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing LIMIT 1 /// where T-SQL emits SELECT TOP 1), and PostgresDialect's information_schema /// catalog SQL — including the deliberate choice to derive the schema list from /// information_schema.tables rather than .schemata, which only a /// granted-but-not-owning principal can prove. /// Env-gated; skipping is the normal outcome. See for the /// gate. With it unset nothing here opens a socket, so the suite is instant and green offline — which /// is the point: a live test that goes red on a laptop teaches people to ignore red. /// Deliberately narrower than . The provider-agnostic /// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned /// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy /// runtime rather than coverage. What is repeated here is exactly what a dialect can get /// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse /// walk an operator authors through. /// [Collection(PostgresCollection.Name)] public sealed class PostgresReadTests { private readonly PostgresFixture _pg; /// Initializes a new instance of the class. /// The seeded live-server fixture (collection-scoped). public PostgresReadTests(PostgresFixture pg) => _pg = pg; // ---- lifecycle: liveness + catalog load ---- /// /// Initialize's liveness check (SELECT 1) and its catalog load both succeed, and the driver /// reports Healthy with the endpoint described credential-free. /// The catalog load is the interesting half, and it is asserted indirectly but /// unambiguously. SqlCatalogLoader throws when the connecting principal sees zero /// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes /// BadNodeIdUnknown. So a Good read (below) is only reachable when /// PostgresDialect.ListSchemasSql returned the fixture schema for a role that merely holds /// USAGE + SELECT on it — the provisioning that information_schema.schemata /// would have answered empty for. /// [Fact] public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey)); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Healthy); health.LastSuccessfulRead.ShouldNotBeNull(); // The endpoint description is what every log line and the Admin UI show. It must name the // database and must not carry the credential that reached the provider. driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase); driver.Endpoint.ShouldNotContain("password", Case.Insensitive); driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); } // ---- key-value model ---- /// A seeded key-value row round-trips as a Good snapshot with its source timestamp. [Fact] public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey)); var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken); var snapshot = snapshots.ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue); // `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than // reinterpreting it through the host's zone — the behaviour this asserts against a real column type. snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc); } // ---- wide-row model: the Postgres-specific row-limit syntax ---- /// /// The topByTimestamp selector resolves to the newest row. /// This is the Postgres-specific leg. The planner spells the row limit through the /// dialect's SingleRowLimitSuffix — a trailing LIMIT 1 — where the SQL Server suite /// exercises a leading SELECT TOP 1. The seed makes the newest row neither the /// first-inserted nor the lowest-keyed one, so losing either the limit or the /// ORDER BY … DESC yields a different, wrong value rather than a coincidentally-right one. /// [Fact] public async Task ReadAsync_realPostgres_topByTimestampSelectsTheNewestRow() { SkipUnlessLive(); await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp")); var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture) .ShouldBe(PostgresFixture.NewestStationOvenTemp); } // ---- integer key columns (STAB-24) ---- /// /// A key-value tag whose key column is integer reads. Authored keyValue is text, and /// Npgsql's default text bind has no integer = text operator — the read failed with a /// provider error before PostgresDialect.ConfigureParameter sent string binds untyped. /// [Fact] public async Task ReadAsync_realPostgres_integerKeyColumnComparesAgainstAuthoredText() { SkipUnlessLive(); await using var driver = await StartAsync(Tag("Sql/IntKey/Tag101", new JsonObject { ["driver"] = "Sql", ["model"] = "KeyValue", ["table"] = PostgresFixture.Qualified("int_tag_values"), ["keyColumn"] = "tag_id", ["keyValue"] = "101", ["valueColumn"] = PostgresFixture.ValueColumn, ["timestampColumn"] = PostgresFixture.TimestampColumn, })); var snapshot = (await driver.ReadAsync(["Sql/IntKey/Tag101"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(77.5); } /// A wide-row whereColumn selector over the integer station_id column reads the matching row. [Fact] public async Task ReadAsync_realPostgres_integerWhereColumnSelectsTheMatchingStation() { SkipUnlessLive(); await using var driver = await StartAsync(Tag("Sql/Station7/OvenTemp", new JsonObject { ["driver"] = "Sql", ["model"] = "WideRow", ["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable), ["columnName"] = "oven_temp", ["timestampColumn"] = PostgresFixture.TimestampColumn, ["rowSelector"] = new JsonObject { ["whereColumn"] = "station_id", ["whereValue"] = "7", }, })); var snapshot = (await driver.ReadAsync(["Sql/Station7/OvenTemp"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(180.5); } // ---- catalog case-folding (design §8.1 gate, against a real information_schema) ---- /// /// An operator who authors SQLPOLL.TAG_VALUES against a catalog holding /// sqlpoll.tag_values still reads. Pinned as accepted, not rejected — that is what /// SqlCatalog.TryMatch actually does: an exact ordinal hit wins, and failing that a /// unique case-insensitive hit is accepted and the catalog's own spelling substituted before /// anything is quoted into SQL. /// Why this matters more on Postgres than it looks. Postgres folds an unquoted /// identifier to lower case, and PostgresDialect.QuoteIdentifier emits a /// double-quoted — i.e. case-sensitive — name. Without the gate's substitution, the /// authored "TAG_VALUES" would reach the server as a quoted identifier naming an object that /// does not exist, and the tag would fail at query time instead of resolving. So this leg is not /// cosmetic tolerance; it is the only thing making mixed-case authoring work at all. /// The gate refuses only an ambiguous fold — two catalog columns differing solely in /// case — which cannot be produced from this seed and is pinned offline in the unit suites. /// [Fact] public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling() { SkipUnlessLive(); var upper = Tag("Sql/Line1/Speed", new JsonObject { ["driver"] = "Sql", ["model"] = "KeyValue", ["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(), ["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(), ["keyValue"] = PostgresFixture.PresentKey, ["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(), ["timestampColumn"] = PostgresFixture.TimestampColumn.ToUpperInvariant(), }); await using var driver = await StartAsync(upper); var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue(); Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue); } // ---- browse session: the AdminUI's schema → table → column walk ---- /// /// The AdminUI picker's walk — RootAsync (schemas) → ExpandAsync (tables) → /// ExpandAsync (columns) → AttributesAsync (the side panel) — sees the seeded table /// over a real database, and the leaf's mapped driver type is the one /// PostgresDialect.MapColumnType promises for double precision. /// Driven through rather than the internal session, because /// that is the seam the AdminUI actually calls: it also proves the provider gate admits /// Postgres and that the dialect's quoted catalog aliases /// (AS "TABLE_NAME") survive Postgres' lower-casing of unquoted output columns — the /// browse session reads every catalog column by name, so an unquoted alias would fold to /// table_name and the walk would throw on GetOrdinal rather than return an empty /// list. /// [Fact] public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf() { SkipUnlessLive(); var config = new JsonObject { ["provider"] = nameof(SqlProvider.Postgres), ["connectionString"] = _pg.ConnectionString, }.ToJsonString(); await using var session = await new SqlDriverBrowser() .OpenAsync(config, TestContext.Current.CancellationToken); var schemas = await session.RootAsync(TestContext.Current.CancellationToken); var schema = schemas.ShouldHaveSingleItem( "the fixture login can SELECT in exactly one schema, so exactly one is browsable"); schema.DisplayName.ShouldBe(PostgresFixture.Schema); var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken); // Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did // return, which is the only useful diagnostic when a browse walk comes back short. tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable); var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable); var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken); columns.Select(c => c.DisplayName).ShouldBe( [PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]); columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf); // The side panel is what the browse-commit builds a tag from: the table has to travel with the // column, or the commit falls through to a generic blob the typed editor cannot read. var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn); var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision var address = attribute.AddressFields.ShouldNotBeNull(); address["schema"].ShouldBe(PostgresFixture.Schema); address["table"].ShouldBe(PostgresFixture.KeyValueTable); address["columnName"].ShouldBe(PostgresFixture.ValueColumn); } // ---- helpers ---- /// Skips the calling test when the live-server gate is not configured or not reachable. private void SkipUnlessLive() { if (_pg.SkipReason is { } reason) Assert.Skip(reason); } /// /// Builds and initializes a driver over the seeded database, with no injected factory — which is /// precisely what makes this suite exercise NpgsqlFactory.Instance, the provider the dialect /// ships. /// private async Task StartAsync(params RawTagEntry[] tags) { var options = new SqlDriverOptions { RawTags = tags, CommandTimeout = TimeSpan.FromSeconds(10), OperationTimeout = TimeSpan.FromSeconds(15), }; var driver = new SqlDriver( options, driverInstanceId: "sql-postgres-integration", dialect: new PostgresDialect(), connectionString: _pg.ConnectionString); try { await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); } catch { await driver.DisposeAsync(); throw; } return driver; } /// A key-value tag over the seeded EAV table. private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject { ["driver"] = "Sql", ["model"] = "KeyValue", ["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable), ["keyColumn"] = PostgresFixture.KeyColumn, ["keyValue"] = key, ["valueColumn"] = PostgresFixture.ValueColumn, ["timestampColumn"] = PostgresFixture.TimestampColumn, }); /// A wide-row tag selected by newest timestamp. private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject { ["driver"] = "Sql", ["model"] = "WideRow", ["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable), ["columnName"] = column, ["timestampColumn"] = PostgresFixture.TimestampColumn, ["rowSelector"] = new JsonObject { ["topByTimestamp"] = PostgresFixture.TimestampColumn, }, }); /// /// Wraps an authored TagConfig object as a raw tag entry. Composed as a /// rather than as an interpolated string literal for the reason /// gives: a hand-written literal that loses a brace produces a /// config the parser silently rejects, which surfaces as a plausible-looking driver failure. /// private static RawTagEntry Tag(string rawPath, JsonObject config) => new(rawPath, config.ToJsonString(), WriteIdempotent: false); } /// /// A real, seeded PostgreSQL database standing in for the source the Sql driver polls — /// the Postgres counterpart of . /// Env-gated, and the gate is checked before any socket is touched. This fixture opens /// nothing unless and are both set; absent, /// it records and every test calls Assert.Skip. That is what keeps /// dotnet test genuinely green (and instant) on the macOS dev box this is usually run from. /// This fixture does not seed — the compose stack does /// (Docker/postgres/docker-compose.yml + seed-pg.sql), and that is deliberate rather /// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is /// not: the connecting principal holds USAGE + SELECT and could not create /// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a /// superuser, and the granted-not-owning provisioning that /// PostgresDialect.ListSchemasSql exists for would never be exercised. /// public sealed class PostgresFixture : IAsyncLifetime { /// Server endpoint in host:port form (e.g. 10.100.0.35:15432). Absent ⇒ skip. public const string EndpointEnvVar = "SQL_PG_ENDPOINT"; /// The login. Defaults to , the user the seed grants. public const string UserEnvVar = "SQL_PG_USER"; /// The password. Required — never defaulted (see ). public const string PasswordEnvVar = "SQL_PG_PASSWORD"; /// The unprivileged login the seed creates and grants USAGE + SELECT to. public const string DefaultUser = "otopcua"; /// Postgres' default port, used when carries no port. public const int DefaultPort = 5432; /// The database the compose stack creates (POSTGRES_DB) and the seed targets. public const string FixtureDatabase = "sqlpollfixture"; /// /// The schema every seeded object lives in — not public, so every authored /// table is a two-part name and the planner's qualified quoting is exercised, and so the /// seed's drop-and-recreate is bounded to objects this fixture owns. /// public const string Schema = "sqlpoll"; /// The Application Name the driver's connections carry, for server-side attribution. public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg"; /// Seconds a connect attempt may take before the fixture calls the server unreachable. private const int ProbeConnectTimeoutSeconds = 5; // ---- key-value (EAV) source ---- /// The key-value table: tag_name varchar(128), num_value double precision NULL, sample_ts timestamp(3). public const string KeyValueTable = "tag_values"; /// The key-value table's key column. public const string KeyColumn = "tag_name"; /// The key-value table's value column. public const string ValueColumn = "num_value"; /// The source-timestamp column carried by every seeded table. public const string TimestampColumn = "sample_ts"; /// A key whose row exists and whose value cell holds . public const string PresentKey = "Line1.Speed"; /// The value seeded for . public const double PresentValue = 42.0; /// The UTC source timestamp seeded for . public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); // ---- wide-row source ---- /// The wide-row table: station_id integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3). public const string WideRowTable = "latest_status"; /// /// The oven_temp of the row holding the newest sample_ts — what a /// topByTimestamp selector must resolve to. Its row is neither the first inserted nor the /// lowest-keyed, so a plan that lost its ORDER BY … DESC or its LIMIT 1 picks a /// different value and the test says so. /// public const double NewestStationOvenTemp = 210.25; /// Why the suite is skipping, or when the server is reachable. public string? SkipReason { get; private set; } /// The connection string to hand the code under test. Empty while is set. public string ConnectionString { get; private set; } = string.Empty; /// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials. public string Server { get; private set; } = string.Empty; /// Two-part name of a seeded object, in the form an authored tag's table carries. /// The bare table or view name. /// The schema.table name. public static string Qualified(string table) => $"{Schema}.{table}"; /// /// Resolves the gate and probes the server with the dialect's own LivenessSql. A failure to /// connect is the offline case and becomes a ; nothing is created or /// seeded here (the compose stack owns that). /// /// A task that represents the asynchronous operation. public async ValueTask InitializeAsync() { if (ResolveConnectionString() is not { } resolved) { SkipReason = $"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " + $"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " + "fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " + "without these."; return; } ConnectionString = resolved; var dialect = new PostgresDialect(); try { var connection = dialect.Factory.CreateConnection() ?? throw new InvalidOperationException("Npgsql returned no connection object."); await using (connection.ConfigureAwait(false)) { connection.ConnectionString = ConnectionString; await connection.OpenAsync().ConfigureAwait(false); var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { command.CommandText = dialect.LivenessSql; await command.ExecuteScalarAsync().ConfigureAwait(false); } } } catch (Exception ex) { SkipReason = $"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " + $"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" + $"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip."; } } /// Holds no long-lived connection, so there is nothing to tear down. /// A completed task. public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// /// Reads the gate and composes the connection string through the dialect's own provider /// factory (), setting keys via /// the abstract indexer. /// That indirection is deliberate: it keeps this project free of a direct Npgsql /// PackageReference, so the suite can only ever exercise the provider version the driver /// actually ships (see the csproj's no-direct-provider-reference note). Every key used here — /// Host, Port, Database, Username, Password, Timeout, /// Application Name — was verified to be accepted through that indexer, so no /// provider-specific property is needed. /// /// The connection string, or — touching no socket — when unconfigured. private string? ResolveConnectionString() { if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null; // A password is required rather than defaulted: guessing one would turn a misconfiguration into a // failed login, which is how accounts get locked out on a server that is not a throwaway. if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null; var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : DefaultUser; var (host, port) = SplitEndpoint(endpoint, DefaultPort); Server = $"{host}:{port}"; var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder() ?? throw new InvalidOperationException("Npgsql returned no connection-string builder."); builder["Host"] = host; builder["Port"] = port; builder["Database"] = FixtureDatabase; builder["Username"] = user; builder["Password"] = password; builder["Timeout"] = ProbeConnectTimeoutSeconds; builder["Application Name"] = DriverApplicationName; return builder.ConnectionString; } /// /// Splits a host:port endpoint, defaulting the port when absent or unparseable. Split on the /// LAST colon so a bracketed IPv6 literal keeps its own colons. /// /// The authored endpoint. /// The port to use when none is carried. /// The host and port. internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort) { var separator = endpoint.LastIndexOf(':'); if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort); var host = endpoint[..separator]; return int.TryParse( endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) ? (host, port) : (endpoint, defaultPort); } } /// /// Collection definition so the gate is resolved once per test session rather than once per test /// class — the same shape as . /// [CollectionDefinition(Name)] public sealed class PostgresCollection : ICollectionFixture { /// The collection name test classes reference. public const string Name = "SqlPostgresServer"; }