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", "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 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); } [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_some_unique_index() { // v2 dropped the "per-generation" qualifier from namespace uniqueness when live-edit // replaced draft/publish. The v2 index name is implementation-defined; assert that // *some* unique index exists on Namespace to catch unintentional index drops. 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.ShouldNotBeEmpty(); } 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(); }