Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
T
Joseph Doherty cbb882293b refactor(drivers): delete the inert driver-tier recycle machinery (#522)
The tier system was documented, operator-authorable, and inert. Deleted rather than activated,
because its premise is gone rather than merely unused.

"Tier C" meant a driver running out-of-process behind an IDriverSupervisor that could restart its
Host without tearing down the OPC UA session. No such process exists anywhere: Galaxy reaches
MXAccess over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process
Galaxy.Host/Proxy/Shared projects) and FOCAS has run in-process since its managed wire client
landed 2026-04-24. Consistently, IDriverSupervisor had ZERO implementations and there was nothing
for one to implement against.

The issue understated the inertness. It says the Tier-C-only protections never engaged, implying
the Tier A/B parts did. They did not: nothing constructs MemoryTracking, MemoryRecycle or
ScheduledRecycleScheduler outside their own unit tests, so the whole Core/Stability recycle layer
was dead — meaning option (a), "pass real tiers", was never a flag flip. It would have meant
writing the wiring that never existed AND arming it.

Deleted: MemoryTracking, MemoryRecycle, ScheduledRecycleScheduler, IDriverSupervisor, the
vestigial DriverTypeRegistry (referenced only by its own tests), and the RecycleIntervalSeconds
knob — which the AdminUI let an operator author and the parser validated while it configured
nothing.

DriverTier itself SURVIVES and is load-bearing: DriverResilienceOptions.GetTierDefaults supplies
the real per-capability timeout/retry/breaker policies via DriverFactoryRegistry.GetTier and
DriverCapabilityInvokerFactory. Only the isolation-and-recycle layer above it is gone.

Deliberately NOT deleted:

- WedgeDetector came along in the same directory and is equally dead in production, but it is
  tier-agnostic and is not recycle machinery — it only shares the folder. Restored rather than
  swept up in a decision that was not about it.
- IDriver.GetMemoryFootprint() and FlushOptionalCachesAsync() lose their only consumer here.
  Removing them touches all 12 drivers and every test stub, so they are documented as
  consumerless and filed as #525 instead of buried in this diff.

Compatibility: a deployed ResilienceConfig blob still carrying "recycleIntervalSeconds" parses
cleanly (unknown keys are ignored — guarded by a new test, because a blob that suddenly failed to
parse would fall back to tier defaults and silently discard the operator's real overrides), and
the AdminUI's preserve-unknown-keys bag keeps the key rather than rewriting stored config on an
unrelated edit.

The 01/U-6 knob-inertness guard carried an explicit carve-out admitting RecycleIntervalSeconds was
dormant and out of scope; that carve-out is now gone, so the expected set is literally what the
test's name claims.

Note: Host.IntegrationTests has 2 failures (DriverProbeRegistrationTests.is_idempotent,
PrimaryGateFailoverTests) — verified pre-existing by reproducing both on clean master dc9d947b.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-30 04:44:04 -04:00

222 lines
9.2 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 was dropped in Gitea #521 — nothing ever wrote a row, and per-cluster
// mesh Phase 4 made the publisher its entity doc described unbuildable (a driver-only node
// has no ConfigDb connection). Per-host connectivity now rides DriverHealthChanged to /hosts.
"DriverInstanceResilienceStatus",
"LdapGroupRoleMapping",
// v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables.
// ScriptedAlarmState was dropped in per-cluster mesh Phase 4 (Task 9) — scripted-alarm
// condition state moved to the replicated LocalDb store.
"Script", "ScriptedAlarm",
// 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();
}