using Microsoft.Data.SqlClient; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// Introspects the applied schema via sys.* / INFORMATION_SCHEMA.* to confirm that /// the Fluent-API DbContext produces the exact structure specified in /// docs/v2/config-db-schema.md. Any change here is a deliberate decision — update the /// schema doc first, then these tests. /// [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 QueryStrings(string sql) { using var conn = _fixture.OpenConnection(); using var cmd = new SqlCommand(sql, conn); using var reader = cmd.ExecuteReader(); var result = new List(); while (reader.Read()) result.Add(reader.GetString(0)); return result; } private List QueryRows(string sql, Func project) { using var conn = _fixture.OpenConnection(); using var cmd = new SqlCommand(sql, conn); using var reader = cmd.ExecuteReader(); var result = new List(); 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(); }