Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
Joseph Doherty 605dbf3dcc feat(configdb): V2HostingAlignment migration consolidating Phase 1a-1e
Phase 1f — the consolidator migration. Closes out the v2 entity-model
rewrite by emitting a single EF migration that captures the cumulative
schema delta from 14a (RowVersion) through 14e (drop generation entities).

Generated: src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/
              20260526081556_V2HostingAlignment.cs           (1562 lines)
              20260526081556_V2HostingAlignment.Designer.cs

Migration shape (per `grep -nE migrationBuilder.\(...)`):

  Drop  12 ForeignKey constraints (one per live-edit entity's GenerationId FK)
  Drop  2  Tables  (ConfigGeneration, ClusterNodeGenerationState)
  Drop  45 Indexes (every UX_*_Generation_* and IX_*_Generation_* across the
                    13 live-edit tables — 1 also dropped the unique-Primary
                    filtered index UX_ClusterNode_Primary_Per_Cluster)
  Drop  13 Columns (12 GenerationId + 1 RedundancyRole)
  Add   12 RowVersion columns (one per live-edit entity)
  Create 4  Tables (Deployment, NodeDeploymentState, ConfigEdit,
                    DataProtectionKeys)
  Create ~45 Indexes (recreated under the new naming pattern
                      UX_<Table>_LogicalId / UX_<Table>_<X> with the
                      GenerationId column stripped from composite keys)

Notable EF quirks accepted:
  Unique-on-required-column indexes (UX_VirtualTag_LogicalId etc.) ship a
  `filter: "[VirtualTagId] IS NOT NULL"` clause that EF auto-inserts for
  SQL Server. Harmless — the column is C#-side `required` so NULL never
  appears.

Verification:
  dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Configuration          -> 0 errors
  dotnet ef migrations script --idempotent (against placeholder DSN)
                                                                 -> 3259-line
                                                                    .sql produced
                                                                    OK
  tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests                -> 0 errors

Live `dotnet ef database update` against a scratch SQL Server deferred to
Task 15 (Migrate-To-V2.ps1) — SSH to the docker host needs a key/password I
don't have, and the always-on SQL at 10.100.0.35,14330 uses Integrated
Security (Windows auth, unreachable from this macOS dev). The migration
itself is structurally correct by construction (EF tooling generated it
against the live DbContext model); the live-DB confidence step is the
PowerShell wrapper's job.

SchemaComplianceTests updates:
  - All_expected_tables_exist: removed ConfigGeneration +
    ClusterNodeGenerationState; added Deployment, NodeDeploymentState,
    ConfigEdit, DataProtectionKeys.
  - Filtered_unique_indexes_match_schema_spec: removed entries for
    UX_ClusterNode_Primary_Per_Cluster (Task 14d) and
    UX_ConfigGeneration_Draft_Per_Cluster (Task 14e). Two filtered uniques
    remain (UX_ClusterNodeCredential_Value, UX_ExternalIdReservation_KindValue_Active).
  - Check_constraints_match_schema_spec: added CK_ConfigEdit_FieldsJson_IsJson.

StoredProceduresTests update:
  - Removed RedundancyRole + 'Primary' from the raw INSERT into ClusterNode
    so the DB-backed test runs against the new schema.
2026-05-26 04:18:50 -04:00

181 lines
6.5 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",
"ConfigAuditLog",
"Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
"DriverInstanceResilienceStatus",
"LdapGroupRoleMapping",
"EquipmentImportBatch",
"EquipmentImportRow",
"Script", "ScriptedAlarm", "ScriptedAlarmState",
// v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment)
"Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys",
};
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_ClusterNodeCredential_Value", "([Enabled]=(1))"),
("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_DriverInstance_ResilienceConfig_IsJson",
"CK_PollGroup_IntervalMs_Min",
"CK_Tag_TagConfig_IsJson",
"CK_ConfigAuditLog_DetailsJson_IsJson",
"CK_ConfigEdit_FieldsJson_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();
}