4d39b98564
DbContext (OtOpcUaConfigDbContext): - Drop Namespace/EquipmentImportBatch/EquipmentImportRow DbSets + Configure methods. - Add RawFolder/TagGroup/UnsTagReference DbSets + Configure methods with sibling-name filtered unique indexes over nullable parents (two-filtered pattern per level). - Reshape DriverInstance (RawFolderId + UX_DriverInstance_Folder_Name/_ClusterRoot_Name), Tag (required DeviceId, nullable TagGroupId, UX_Tag_Group_Name/_Device_Name, IX_Tag_Device), Equipment (drop DriverInstanceId/DeviceId + IX_Equipment_Driver), Device (UX_Device_Driver_Name). Migration squash -> V3Initial: - Delete the whole Migrations/ folder; scaffold one V3Initial (applies clean to a fresh DB). - Port the hand-written procs + AuthorizationGrants into V3Initial.StoredProcedures.cs. The v1 generation-model procs (ConfigGeneration/GenerationId, dead since V2HostingAlignment) are rewritten as v3-schema-valid retirement stubs that preserve the two-role EXECUTE-grant surface + AuthorizationTests behaviors; sp_ComputeGenerationDiff now references the v3 tables; sp_ReleaseExternalIdReservation ported verbatim. Compile-fixes for the build gate (Wave-C-owned, minimal + TODO(v3 WP4) markers): - DraftSnapshot/DraftSnapshotFactory: drop the retired Namespaces list. - DraftValidator: delete namespace-binding + Galaxy-FullName rules; collision rule now VirtualTag-only. Seeds -> v3 schema: - seed-clusters.sql summary SELECTs; all 5 scripts/smoke/seed-*.sql rewritten (DriverInstance RawFolderId, Device+DeviceConfig endpoint, Tag DeviceId, UnsTagReference; no ConfigGeneration/ Namespace/sp_PublishGeneration). Verified: all seeds apply clean against a fresh V3Initial DB. Tests (Configuration.Tests, 107 pass): - SchemaComplianceTests rewritten to v3 tables + filtered indexes. - New RawSchemaIntegrityTests: DB-enforced sibling-name uniqueness per raw level + UnsTagReference (Equipment,Tag) uniqueness. - Delete DraftValidatorGalaxyFullNameCorpusTests (WP2/WP6 replaces); fix DraftValidatorTests + DraftSnapshotFactoryTests to v3 shapes; repoint scripting-migration existence test to V3Initial.
218 lines
8.7 KiB
C#
218 lines
8.7 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;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="SchemaComplianceTests"/> class.</summary>
|
|
/// <param name="fixture">The database schema compliance fixture.</param>
|
|
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
|
|
|
/// <summary>Verifies that all expected tables exist in the schema.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that filtered unique indexes match the schema specification.</summary>
|
|
[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");
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that check constraints match the schema specification.</summary>
|
|
[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}");
|
|
}
|
|
|
|
/// <summary>Verifies that JSON check constraints use the ISJSON function.</summary>
|
|
[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}");
|
|
}
|
|
|
|
/// <summary>Verifies that the Deployment Status column exists.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that Equipment carries OPC 4.0 10 identity fields.</summary>
|
|
[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}");
|
|
}
|
|
|
|
/// <summary>Verifies the retired v2 tables (Namespace, EquipmentImportBatch/Row) are absent.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Verifies the new v3 Raw-tree tables each carry their logical-id unique index.</summary>
|
|
[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<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();
|
|
}
|