Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs
T
Joseph Doherty 63372e757a feat(sql): MySql provider over MySqlConnector, one shared provider map (lmxopcua#753)
SqlProvider.MySql is now a constructed backend instead of a reserved name.

- MySqlDialect (Driver.Sql.Dialect): MySqlConnector 2.6.2 (MIT, fully managed,
  no vulnerable packages), backtick quoting with MySQL's 64-char / no-trailing-
  space rules, information_schema catalog SQL with the database as the schema
  (system databases excluded, aliases pinned), trailing LIMIT, a type map that
  also reads the reader-side spellings (INT UNSIGNED -> UInt32, BOOL -> Boolean).
- SqlDialects: the one provider -> dialect map. The factory, the Test Connect
  probe and the browse picker each carried their own switch (and drifted once);
  they now all read this, and their refusals list the supported/reserved sets
  from it.
- AdminUI driver form offers MySql.
- Tests: MySqlDialectTests (golden quoting/catalog/type map/planner SQL); the
  provider-gate tests in factory, probe, browser and form now treat MySql as
  supported and Odbc as the reserved control. MySqlReadTests + Docker/mysql
  (throwaway mysql:8.4, SELECT-only login): liveness, KV/wide-row/topByTimestamp,
  INT keys vs authored text, case folding against case-sensitive table names,
  catalog rejection, browse walk, factory+probe, named query with a bound
  parameter, and a writing stored function whose effect the rolled-back
  transaction discards.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00

187 lines
9.6 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Guards the AdminUI <c>SqlDriverForm</c>'s driver-config output against the runtime factory that consumes
/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
/// composer-owned <c>rawTags</c> / inert <c>allowWrites</c> leaking into the authored blob. A numeric enum
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
/// </summary>
public sealed class SqlDriverFormContractTests
{
// The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
private static readonly JsonSerializerOptions FormOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
// ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
[Theory]
// Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
// Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
{
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
var previous = Environment.GetEnvironmentVariable(envVar);
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
try
{
var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
driver.ShouldNotBeNull();
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}
[Fact]
public void Factory_rejects_a_blob_missing_connectionStringRef()
{
// The form marks connectionStringRef required; the factory is the authoritative enforcement.
var ex = Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
ex.Message.ShouldContain("connectionStringRef");
}
// ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
[Fact]
public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
{
// Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
var json = JsonSerializer.Serialize(dto, FormOptions);
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
}
[Fact]
public void Provider_round_trips_as_the_SqlServer_name()
{
var json = JsonSerializer.Serialize(
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
json.ShouldContain("\"SqlServer\"");
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
}
// ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ----
/// <summary>
/// The dropdown offers SqlServer + Postgres + Oracle, so each must survive
/// <c>FormModel.ToDto()</c> → JSON → <c>FormModel.FromDto()</c> as a NAME string. The razor markup
/// itself is not reachable (no bUnit), so this pins the model half of the change: a provider the
/// dropdown can select but the form model cannot carry back would silently reset to SqlServer on
/// the next modal open.
/// </summary>
[Theory]
[InlineData(SqlProvider.SqlServer, "SqlServer")]
[InlineData(SqlProvider.Postgres, "Postgres")]
[InlineData(SqlProvider.Oracle, "Oracle")]
[InlineData(SqlProvider.MySql, "MySql")]
public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name)
{
var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" };
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
json.ShouldContain($"\"provider\":\"{name}\"");
json.ShouldNotContain($"\"provider\":{(int)provider}"); // never an ordinal — that faults the factory read
var reloaded = SqlDriverForm.FormModel.FromDto(
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!);
reloaded.Provider.ShouldBe(provider);
reloaded.ConnectionStringRef.ShouldBe("MesStaging");
}
/// <summary>
/// Odbc stays reserved-and-rejected in this build, so the dropdown deliberately does not offer it. Nothing in the form model blocks the value — this records the intent so a later "the enum has
/// five members, the dropdown should too" edit has to argue with a test.
/// </summary>
[Theory]
[InlineData(SqlProvider.Odbc)]
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
{
var json = JsonSerializer.Serialize(
new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions);
json.ShouldContain($"\"provider\":\"{reserved}\"");
}
// ---- named queries (lmxopcua#753): the form refuses exactly what the factory refuses ----
/// <summary>
/// The driver form's queries editor consults the runtime's own rules
/// (<see cref="SqlNamedQueryRules.Describe"/>), so what the form keeps is what the factory constructs and
/// what it refuses the factory refuses too. Driven through the real factory entry point, so a rule added
/// on one side only breaks this test.
/// </summary>
[Theory]
[InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":1}}}""", true)]
[InlineData("""{"q":{"sql":"WITH l AS (SELECT a FROM t) SELECT a FROM l;","maxRows":10}}""", true)]
[InlineData("""{"q":{"sql":"UPDATE t SET a = 1"}}""", false)]
[InlineData("""{"q":{"sql":"SELECT a INTO t2 FROM t"}}""", false)]
[InlineData("""{"q":{"sql":"SELECT 1; SELECT 2"}}""", false)]
[InlineData("""{"q":{"sql":"SELECT a FROM t","maxRows":0}}""", false)]
[InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":{"nested":1}}}}""", false)]
[InlineData("""{"q":{"sql":""}}""", false)]
public void Form_queries_editor_and_factory_agree(string queriesJson, bool accepted)
{
var formError = SqlDriverForm.FormModel.TryParseQueries(queriesJson, out _);
(formError is null).ShouldBe(accepted, formError);
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
var previous = Environment.GetEnvironmentVariable(envVar);
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
try
{
var blob = $$"""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","queries":{{queriesJson}}}""";
if (accepted) { SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", blob).ShouldNotBeNull(); }
else
{
Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", blob));
}
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}
[Fact]
public void Queries_survive_the_form_model_round_trip_and_a_typo_keeps_the_last_valid_set()
{
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
"""{"provider":"SqlServer","connectionStringRef":"R","queries":{"q":{"sql":"SELECT a FROM t"}}}""", FormOptions)!;
var form = SqlDriverForm.FormModel.FromDto(dto);
form.SetQueriesJson("{ not json");
form.QueriesError.ShouldNotBeNull();
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
json.ShouldContain("\"queries\":{\"q\":{\"sql\":\"SELECT a FROM t\"}}");
}
}