Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
Joseph Doherty 8464e3f376 Phase 3 PR 33 — DriverHostStatus entity + EF migration (data-layer for LMX #7). New DriverHostStatus entity with composite key (NodeId, DriverInstanceId, HostName) persists each server node's per-host connectivity view — one row per (server node, driver instance, probe-reported host), which means a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6 rows because each server node owns its own runtime view of the shared host topology, not 3. Fields: NodeId (64), DriverInstanceId (64), HostName (256 — fits Galaxy FQDNs and Modbus host:port strings), State (DriverHostState enum — Unknown/Running/Stopped/Faulted, persisted as nvarchar(16) via HasConversion<string> so DBAs inspecting the table see readable state names not ordinals), StateChangedUtc + LastSeenUtc (datetime2(3) — StateChangedUtc tracks actual transitions while LastSeenUtc advances on every publisher heartbeat so the Admin UI can flag stale rows from a crashed Server independent of State), Detail (nullable 1024 — exception message from the driver's probe when Faulted, null otherwise).
DriverHostState enum lives in Configuration.Enums/ rather than reusing Core.Abstractions.HostState so the Configuration project stays free of driver-runtime dependencies (it's referenced by both the Admin process and the Server process, so pulling in the driver-abstractions assembly to every Admin build would be unnecessary weight). The server-side publisher hosted service (follow-up PR 34) will translate HostStatusChangedEventArgs.NewState to this enum on every transition.
No foreign key to ClusterNode — a Server may start reporting host status before its ClusterNode row exists (first-boot bootstrap), and we'd rather keep the status row than drop it. The Admin-side service that renders the dashboard will left-join on NodeId when presenting. Two indexes declared: IX_DriverHostStatus_Node drives the per-cluster drill-down (Admin UI joins ClusterNode on ClusterId to pick which NodeIds to fetch), IX_DriverHostStatus_LastSeen drives the stale-row query (now - LastSeen > threshold).
EF migration AddDriverHostStatus creates the table + PK + both indexes. Model snapshot updated. SchemaComplianceTests expected-tables list extended. DriverHostStatusTests (3 new cases, category SchemaCompliance, uses the shared fixture DB): composite key allows same (host, driver) across different nodes AND same (node, host) across different drivers — both real-world cases the publisher needs to support; upsert-in-place pattern (fetch-by-composite-PK, mutate, save) produces one row not two — the pattern the publisher will use; State enum persists as string not int — reading the DB via ADO.NET returns 'Faulted' not '3'.
Configuration.Tests SchemaCompliance suite: 10 pass / 0 fail (7 prior + 3 new). Configuration build clean. No Server or Admin code changes yet — publisher + /hosts page are PR 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:41 -04:00

174 lines
6.3 KiB
C#

using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Introspects the applied schema via <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> to confirm that
/// the Fluent-API DbContext produces the exact structure specified in
/// <c>docs/v2/config-db-schema.md</c>. Any change here is a deliberate decision — update the
/// schema doc first, then these tests.
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class SchemaComplianceTests
{
private readonly SchemaComplianceFixture _fixture;
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
[Fact]
public void All_expected_tables_exist()
{
var expected = new[]
{
"ServerCluster", "ClusterNode", "ClusterNodeCredential", "ClusterNodeGenerationState",
"ConfigGeneration", "ConfigAuditLog",
"Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
};
var actual = QueryStrings(@"
SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet();
foreach (var table in expected)
actual.ShouldContain(table, $"missing table: {table}");
actual.Count.ShouldBe(expected.Length);
}
[Fact]
public void Filtered_unique_indexes_match_schema_spec()
{
// (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config.
// Kept here as a spec-level source of truth; the test ensures EF generated them verbatim.
var expected = new[]
{
("UX_ClusterNode_Primary_Per_Cluster", "([RedundancyRole]='Primary')"),
("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
("UX_ConfigGeneration_Draft_Per_Cluster", "([Status]='Draft')"),
("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
};
var rows = QueryRows(@"
SELECT i.name AS IndexName, i.filter_definition
FROM sys.indexes i
WHERE i.is_unique = 1 AND i.has_filter = 1;",
r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1)));
foreach (var (name, filter) in expected)
{
var match = rows.FirstOrDefault(x => x.Name == name);
match.Name.ShouldBe(name, $"missing filtered unique index: {name}");
NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter),
$"filter predicate for {name} drifted");
}
}
[Fact]
public void Check_constraints_match_schema_spec()
{
var expected = new[]
{
"CK_ServerCluster_RedundancyMode_NodeCount",
"CK_Device_DeviceConfig_IsJson",
"CK_DriverInstance_DriverConfig_IsJson",
"CK_PollGroup_IntervalMs_Min",
"CK_Tag_TagConfig_IsJson",
"CK_ConfigAuditLog_DetailsJson_IsJson",
};
var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet();
foreach (var ck in expected)
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
}
[Fact]
public void Json_check_constraints_use_IsJson_function()
{
var rows = QueryRows(@"
SELECT cc.name, cc.definition
FROM sys.check_constraints cc
WHERE cc.name LIKE 'CK_%_IsJson';",
r => (Name: r.GetString(0), Definition: r.GetString(1)));
rows.Count.ShouldBeGreaterThanOrEqualTo(4);
foreach (var (name, definition) in rows)
definition.ShouldContain("isjson(", Case.Insensitive,
$"{name} definition does not call ISJSON: {definition}");
}
[Fact]
public void ConfigGeneration_Status_uses_nvarchar_enum_storage()
{
var rows = QueryRows(@"
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS c
WHERE c.TABLE_NAME = 'ConfigGeneration' AND c.COLUMN_NAME = 'Status';",
r => (Column: r.GetString(0), Type: r.GetString(1), Length: r.IsDBNull(2) ? (int?)null : r.GetInt32(2)));
rows.Count.ShouldBe(1);
rows[0].Type.ShouldBe("nvarchar");
rows[0].Length.ShouldNotBeNull();
}
[Fact]
public void Equipment_carries_Opc40010_identity_fields()
{
var columns = QueryStrings(@"
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var col in new[]
{
"EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID",
"Manufacturer", "Model", "SerialNumber",
})
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
}
[Fact]
public void Namespace_has_same_cluster_invariant_index()
{
// Decision #122: namespace logical IDs unique within a cluster + generation. The composite
// unique index enforces that trust boundary.
var indexes = QueryStrings(@"
SELECT i.name
FROM sys.indexes i
JOIN sys.tables t ON i.object_id = t.object_id
WHERE t.name = 'Namespace' AND i.is_unique = 1;").ToList();
indexes.ShouldContain("UX_Namespace_Generation_LogicalId_Cluster");
}
private List<string> QueryStrings(string sql)
{
using var conn = _fixture.OpenConnection();
using var cmd = new SqlCommand(sql, conn);
using var reader = cmd.ExecuteReader();
var result = new List<string>();
while (reader.Read())
result.Add(reader.GetString(0));
return result;
}
private List<T> QueryRows<T>(string sql, Func<SqlDataReader, T> project)
{
using var conn = _fixture.OpenConnection();
using var cmd = new SqlCommand(sql, conn);
using var reader = cmd.ExecuteReader();
var result = new List<T>();
while (reader.Read())
result.Add(project(reader));
return result;
}
private static string? NormalizeFilter(string? filter) =>
filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant();
}