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; /// Initializes a new instance of the class. /// The database schema compliance fixture. public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture; /// Verifies that all expected tables exist in the schema. [Fact] public void All_expected_tables_exist() { var expected = new[] { "ServerCluster", "ClusterNode", "ClusterNodeCredential", "ConfigAuditLog", "UnsArea", "UnsLine", // v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added) "RawFolder", "TagGroup", "UnsTagReference", "DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag", "NodeAcl", "ExternalIdReservation", "DriverHostStatus", "DriverInstanceResilienceStatus", "LdapGroupRoleMapping", // v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables "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); } /// Verifies that filtered unique indexes match the schema specification. [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)"), // v3 sibling-name filtered unique indexes over nullable parent columns. ("UX_RawFolder_Parent_Name", "([ParentRawFolderId] IS NOT NULL)"), ("UX_RawFolder_ClusterRoot_Name", "([ParentRawFolderId] IS NULL)"), ("UX_DriverInstance_Folder_Name", "([RawFolderId] IS NOT NULL)"), ("UX_DriverInstance_ClusterRoot_Name", "([RawFolderId] IS NULL)"), ("UX_TagGroup_Parent_Name", "([ParentTagGroupId] IS NOT NULL)"), ("UX_TagGroup_DeviceRoot_Name", "([ParentTagGroupId] IS NULL)"), ("UX_Tag_Group_Name", "([TagGroupId] IS NOT NULL)"), ("UX_Tag_Device_Name", "([TagGroupId] 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"); } } /// Verifies that check constraints match the schema specification. [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}"); } /// Verifies that JSON check constraints use the ISJSON function. [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}"); } /// Verifies that the Deployment Status column exists. [Fact] public void Deployment_Status_column_exists() { // v2 replaces ConfigGeneration with Deployment. Storage type for Status is design-defined // (currently int via the EF enum mapping); the invariant we care about is that the // column is present. var rows = QueryRows(@" SELECT c.COLUMN_NAME, c.DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS c WHERE c.TABLE_NAME = 'Deployment' AND c.COLUMN_NAME = 'Status';", r => (Column: r.GetString(0), Type: r.GetString(1))); rows.Count.ShouldBe(1); } /// Verifies that Equipment carries OPC 4.0 10 identity fields. [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}"); } /// Verifies the retired v2 tables (Namespace, EquipmentImportBatch/Row) are absent. [Fact] public void Retired_v2_tables_are_absent() { var tables = QueryStrings("SELECT name FROM sys.tables;").ToHashSet(); tables.ShouldNotContain("Namespace"); tables.ShouldNotContain("EquipmentImportBatch"); tables.ShouldNotContain("EquipmentImportRow"); } /// Verifies the new v3 Raw-tree tables each carry their logical-id unique index. [Fact] public void V3_raw_tree_tables_have_logical_id_unique_indexes() { var expected = new[] { ("RawFolder", "UX_RawFolder_LogicalId"), ("TagGroup", "UX_TagGroup_LogicalId"), ("UnsTagReference", "UX_UnsTagReference_LogicalId"), }; foreach (var (table, index) in expected) { var indexes = QueryStrings($@" SELECT i.name FROM sys.indexes i JOIN sys.tables t ON i.object_id = t.object_id WHERE t.name = '{table}' AND i.is_unique = 1;").ToHashSet(); indexes.ShouldContain(index, $"{table} missing unique index {index}"); } } 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(); }