Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
Joseph Doherty cbcaf6593a Phase 6.1 Stream E (data layer) — DriverInstanceResilienceStatus entity + DriverResilienceStatusTracker + EF migration
Ships the data + runtime layer of Stream E. The SignalR hub and Blazor /hosts
page refresh (E.2-E.3) are follow-up work paired with the visual-compliance
review per Phase 6.4 patterns — documented as a deferred follow-up below.

Configuration:
- New entity DriverInstanceResilienceStatus with:
  DriverInstanceId, HostName (composite PK),
  LastCircuitBreakerOpenUtc, ConsecutiveFailures, CurrentBulkheadDepth,
  LastRecycleUtc, BaselineFootprintBytes, CurrentFootprintBytes,
  LastSampledUtc.
- Separate from DriverHostStatus (per-host connectivity view) so a Running
  host that has tripped its breaker or is nearing its memory ceiling shows up
  distinctly on Admin /hosts. Admin page left-joins both for display.
- OtOpcUaConfigDbContext + Fluent-API config + IX_DriverResilience_LastSampled
  index for the stale-sample filter query.
- EF migration: 20260419124034_AddDriverInstanceResilienceStatus.

Core.Resilience:
- DriverResilienceStatusTracker — process-singleton in-memory tracker keyed on
  (DriverInstanceId, HostName). CapabilityInvoker + MemoryTracking +
  MemoryRecycle callers record failure/success/breaker-open/recycle/footprint
  events; a HostedService (Stream E.2 follow-up) samples this tracker every
  5 s and persists to the DB. Pure in-memory keeps tests fast + the core
  free of EF/SQL dependencies.

Tests:
- DriverResilienceStatusTrackerTests (9 new, all pass): tryget-before-write
  returns null; failures accumulate; success resets; breaker/recycle/footprint
  fields populate; per-host isolation; snapshot returns all pairs; concurrent
  writes don't lose counts.
- SchemaComplianceTests: expected-tables list updated to include the new
  DriverInstanceResilienceStatus table.

Full solution dotnet test: 1042 passing (baseline 906, +136 for Phase 6.1 so
far across Streams A/B/C/D/E.1). Pre-existing Client.CLI Subscribe flake
unchanged.

Deferred to follow-up PR (E.2/E.3):
- ResilienceStatusPublisher HostedService that samples DriverResilienceStatusTracker
  every 5 s + upserts DriverInstanceResilienceStatus rows.
- Admin FleetStatusHub SignalR hub pushing LastCircuitBreakerOpenUtc /
  CurrentBulkheadDepth / LastRecycleUtc on change.
- Admin /hosts Blazor column additions (red badge when
  ConsecutiveFailures > breakerThreshold / 2). Visual-compliance reviewer
  signoff alongside Phase 6.4 admin-ui patterns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:43 -04:00

175 lines
6.3 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;
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",
"DriverInstanceResilienceStatus",
};
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<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();
}