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";
}