Closes the server-side/data-layer piece of Phase 6.4 Stream B.2-B.4. The CSV-import preview + modal UI (Stream B.3/B.5) still belongs to the Admin UI follow-up — this PR owns the staging tables + atomic finalise alone. Configuration: - New EquipmentImportBatch entity (Id, ClusterId, CreatedBy, CreatedAtUtc, RowsStaged/Accepted/Rejected, FinalisedAtUtc?). Composite index on (CreatedBy, FinalisedAtUtc) powers the Admin preview modal's "my open batches" query. - New EquipmentImportRow entity — one row per CSV row, 8 required columns from decision #117 + 9 optional from decision #139 + IsAccepted flag + RejectReason. FK to EquipmentImportBatch with cascade delete so DropBatch collapses the whole tree. - EF migration 20260419_..._AddEquipmentImportBatch. - SchemaComplianceTests expected tables list gains the two new tables. Admin.Services.EquipmentImportBatchService: - CreateBatchAsync — new header row, caller-supplied ClusterId + CreatedBy. - StageRowsAsync(batchId, acceptedRows, rejectedRows) — bulk-inserts the parsed CSV rows into staging. Rejected rows carry LineNumberInFile + RejectReason for the preview modal. Throws when the batch is finalised. - DropBatchAsync — removes batch + cascaded rows. Throws when the batch was already finalised (rollback via staging is not a time machine). - FinaliseBatchAsync(batchId, generationId, driverInstanceId, unsLineId) — atomic apply. Opens an EF transaction when the provider supports it (SQL Server in prod; InMemory in tests skips the tx), bulk-inserts every accepted staging row into Equipment, stamps EquipmentImportBatch.FinalisedAtUtc, commits. Failure rolls back so Equipment never partially mutates. Idempotent-under-double-call: second finalise throws ImportBatchAlreadyFinalisedException. - ListByUserAsync(createdBy, includeFinalised) — the Admin preview modal's backing query. OrderByDescending on CreatedAtUtc so the most-recent batch shows first. - Two exception types: ImportBatchNotFoundException + ImportBatchAlreadyFinalisedException. ExternalIdReservation merging (ZTag + SAPID fleet-wide uniqueness) is NOT done here — a narrower follow-up wires it once the concurrent-insert test matrix is green. Tests (10 new EquipmentImportBatchServiceTests, all pass): - CreateBatch populates Id + CreatedAtUtc + zero-ed counters. - StageRows accepted + rejected both persist; counters advance. - DropBatch cascades row delete. - DropBatch after finalise throws. - Finalise translates accepted staging rows → Equipment under the target GenerationId + DriverInstanceId + UnsLineId. - Finalise twice throws. - Finalise of unknown batch throws. - Stage after finalise throws. - ListByUserAsync filters by creator + finalised flag. - Drop of unknown batch is a no-op (idempotent rollback). Full solution dotnet test: 1235 passing (was 1225, +10). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.5 KiB
C#
179 lines
6.5 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",
|
|
"LdapGroupRoleMapping",
|
|
"EquipmentImportBatch",
|
|
"EquipmentImportRow",
|
|
};
|
|
|
|
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_DriverInstance_ResilienceConfig_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();
|
|
}
|