fix(admin): resolve Low code-review findings (Admin-010,011,012)
- Admin-010: vendor Bootstrap 5.3.3 (CSS + JS bundle + maps + provenance README) under wwwroot/lib/bootstrap and reference local paths from App.razor — Admin no longer pulls Bootstrap from jsDelivr. - Admin-011: swap FleetStatusPoller's three plain dictionaries for ConcurrentDictionary so ResetCache can't race a poll tick. - Admin-012: drop the EquipmentId column from EquipmentCsvImporter (per admin-ui.md — equipment id is system-derived from EquipmentUuid); EquipmentImportBatchService and the textarea placeholder updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Admin-010 — admin-ui.md "Tech Stack" requires Bootstrap 5
|
||||
/// "vendored under wwwroot/lib/bootstrap/" so the Admin app has no third-party
|
||||
/// runtime dependency and works in air-gapped fleet deployments. These tests
|
||||
/// guard against a future re-introduction of the cdn.jsdelivr.net references
|
||||
/// in App.razor.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BootstrapVendoringTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppRazor_does_not_reference_a_remote_CDN_for_bootstrap()
|
||||
{
|
||||
var appRazor = File.ReadAllText(ResolveAdminPath("Components/App.razor"));
|
||||
|
||||
appRazor.ShouldNotContain("cdn.jsdelivr.net",
|
||||
customMessage: "Admin-010: Bootstrap must be served from the vendored copy under wwwroot/lib/bootstrap/, not jsDelivr — air-gapped deployments cannot reach the public CDN.");
|
||||
appRazor.ShouldNotContain("cdnjs.cloudflare.com",
|
||||
customMessage: "Admin-010: third-party CDN references regress the vendoring requirement.");
|
||||
appRazor.ShouldNotContain("unpkg.com",
|
||||
customMessage: "Admin-010: third-party CDN references regress the vendoring requirement.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppRazor_references_vendored_bootstrap_assets()
|
||||
{
|
||||
var appRazor = File.ReadAllText(ResolveAdminPath("Components/App.razor"));
|
||||
|
||||
appRazor.ShouldContain("lib/bootstrap/css/bootstrap.min.css",
|
||||
customMessage: "App.razor must load the vendored Bootstrap stylesheet.");
|
||||
appRazor.ShouldContain("lib/bootstrap/js/bootstrap.bundle.min.js",
|
||||
customMessage: "App.razor must load the vendored Bootstrap JS bundle.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vendored_bootstrap_assets_exist_under_wwwroot_lib_bootstrap()
|
||||
{
|
||||
var root = ResolveAdminPath("wwwroot/lib/bootstrap");
|
||||
|
||||
Directory.Exists(root).ShouldBeTrue($"expected vendored bootstrap directory at '{root}'");
|
||||
File.Exists(Path.Combine(root, "css", "bootstrap.min.css")).ShouldBeTrue("bootstrap.min.css missing");
|
||||
File.Exists(Path.Combine(root, "js", "bootstrap.bundle.min.js")).ShouldBeTrue("bootstrap.bundle.min.js missing");
|
||||
|
||||
// Sanity-check non-empty (a zero-byte placeholder would still pass File.Exists).
|
||||
new FileInfo(Path.Combine(root, "css", "bootstrap.min.css")).Length.ShouldBeGreaterThan(100_000);
|
||||
new FileInfo(Path.Combine(root, "js", "bootstrap.bundle.min.js")).Length.ShouldBeGreaterThan(50_000);
|
||||
}
|
||||
|
||||
/// <summary>Resolve a path under the Admin source project from the test runner's bin folder.</summary>
|
||||
private static string ResolveAdminPath(string relative)
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(typeof(BootstrapVendoringTests).Assembly.Location)!;
|
||||
// tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/bin/Debug/net10.0 -> ../../../../../src/Server/...
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(asmDir, "..", "..", "..", "..", "..", ".."));
|
||||
return Path.Combine(repoRoot, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentCsvImporterTests
|
||||
{
|
||||
// Admin-012: header no longer includes EquipmentId — that field is system-derived.
|
||||
private const string Header =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
||||
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
||||
|
||||
[Fact]
|
||||
public void EmptyFile_Throws()
|
||||
@@ -20,7 +21,7 @@ public sealed class EquipmentCsvImporterTests
|
||||
[Fact]
|
||||
public void MissingVersionMarker_Throws()
|
||||
{
|
||||
var csv = "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x,x";
|
||||
var csv = "ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("# OtOpcUaCsv v1");
|
||||
@@ -30,8 +31,8 @@ public sealed class EquipmentCsvImporterTests
|
||||
public void MissingRequiredColumn_Throws()
|
||||
{
|
||||
var csv = "# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentId,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,mc,sap,eq1,Name1,area,line";
|
||||
"ZTag,MachineCode,SAPID,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,mc,sap,Name1,area,line";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("EquipmentUuid");
|
||||
@@ -40,7 +41,7 @@ public sealed class EquipmentCsvImporterTests
|
||||
[Fact]
|
||||
public void UnknownColumn_Throws()
|
||||
{
|
||||
var csv = Header + ",WeirdColumn\nz1,mc,sap,eq1,uu,Name1,area,line,value";
|
||||
var csv = Header + ",WeirdColumn\nz1,mc,sap,uu,Name1,area,line,value";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("WeirdColumn");
|
||||
@@ -50,8 +51,8 @@ public sealed class EquipmentCsvImporterTests
|
||||
public void DuplicateColumn_Throws()
|
||||
{
|
||||
var csv = "# OtOpcUaCsv v1\n" +
|
||||
"ZTag,ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,z1,mc,sap,eq,uu,Name,area,line";
|
||||
"ZTag,ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,z1,mc,sap,uu,Name,area,line";
|
||||
|
||||
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
}
|
||||
@@ -59,7 +60,7 @@ public sealed class EquipmentCsvImporterTests
|
||||
[Fact]
|
||||
public void ValidSingleRow_RoundTrips()
|
||||
{
|
||||
var csv = Header + "\nz-001,MC-1,SAP-1,eq-001,uuid-1,Oven-A,Warsaw,Line-1";
|
||||
var csv = Header + "\nz-001,MC-1,SAP-1,uuid-1,Oven-A,Warsaw,Line-1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -76,8 +77,8 @@ public sealed class EquipmentCsvImporterTests
|
||||
public void OptionalColumns_Populated_WhenPresent()
|
||||
{
|
||||
var csv = "# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
|
||||
"z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
|
||||
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
|
||||
"z-1,MC,SAP,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -93,7 +94,7 @@ public sealed class EquipmentCsvImporterTests
|
||||
[Fact]
|
||||
public void BlankRequiredField_Rejects_Row()
|
||||
{
|
||||
var csv = Header + "\nz-1,MC,SAP,eq,uuid,,Warsaw,Line1"; // Name blank
|
||||
var csv = Header + "\nz-1,MC,SAP,uuid,,Warsaw,Line1"; // Name blank
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -106,8 +107,8 @@ public sealed class EquipmentCsvImporterTests
|
||||
public void DuplicateZTag_Rejects_SecondRow()
|
||||
{
|
||||
var csv = Header +
|
||||
"\nz-1,MC1,SAP1,eq1,u1,N1,A,L1" +
|
||||
"\nz-1,MC2,SAP2,eq2,u2,N2,A,L1";
|
||||
"\nz-1,MC1,SAP1,u1,N1,A,L1" +
|
||||
"\nz-1,MC2,SAP2,u2,N2,A,L1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -121,7 +122,7 @@ public sealed class EquipmentCsvImporterTests
|
||||
{
|
||||
// RFC 4180: "" inside a quoted field is an escaped quote.
|
||||
var csv = Header +
|
||||
"\n\"z-1\",\"MC\",\"SAP,with,commas\",\"eq\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
|
||||
"\n\"z-1\",\"MC\",\"SAP,with,commas\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -133,7 +134,7 @@ public sealed class EquipmentCsvImporterTests
|
||||
[Fact]
|
||||
public void MismatchedColumnCount_Rejects_Row()
|
||||
{
|
||||
var csv = Header + "\nz-1,MC,SAP,eq,uuid,Name,Warsaw"; // missing UnsLineName cell
|
||||
var csv = Header + "\nz-1,MC,SAP,uuid,Name,Warsaw"; // missing UnsLineName cell
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -146,9 +147,9 @@ public sealed class EquipmentCsvImporterTests
|
||||
public void BlankLines_BetweenRows_AreIgnored()
|
||||
{
|
||||
var csv = Header +
|
||||
"\nz-1,MC,SAP,eq1,u1,N1,A,L1" +
|
||||
"\nz-1,MC,SAP,u1,N1,A,L1" +
|
||||
"\n" +
|
||||
"\nz-2,MC,SAP,eq2,u2,N2,A,L1";
|
||||
"\nz-2,MC,SAP,u2,N2,A,L1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
@@ -159,8 +160,9 @@ public sealed class EquipmentCsvImporterTests
|
||||
[Fact]
|
||||
public void Header_Constants_Match_Decision_117_and_139()
|
||||
{
|
||||
// Admin-012: EquipmentId is intentionally absent — derived from EquipmentUuid at finalise time.
|
||||
EquipmentCsvImporter.RequiredColumns.ShouldBe(
|
||||
["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
||||
["ZTag", "MachineCode", "SAPID", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
||||
|
||||
EquipmentCsvImporter.OptionalColumns.ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Admin-012 — <c>admin-ui.md</c> ("Equipment CSV import", revised after
|
||||
/// adversarial review finding #4) requires no <c>EquipmentId</c> column: it is
|
||||
/// system-derived (<c>'EQ-' + first 12 hex chars of EquipmentUuid</c>) and "never
|
||||
/// accepted from CSV imports". Operator-supplied EquipmentId would mint duplicate
|
||||
/// equipment identity on typos.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentCsvNoEquipmentIdColumnTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequiredColumns_does_not_include_EquipmentId()
|
||||
{
|
||||
EquipmentCsvImporter.RequiredColumns
|
||||
.ShouldNotContain("EquipmentId",
|
||||
customMessage: "Admin-012: admin-ui.md forbids an EquipmentId column on the CSV import — it is system-derived from EquipmentUuid.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionalColumns_does_not_include_EquipmentId()
|
||||
{
|
||||
EquipmentCsvImporter.OptionalColumns
|
||||
.ShouldNotContain("EquipmentId",
|
||||
customMessage: "Admin-012: EquipmentId must not be an optional column either — it is never accepted from the CSV.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentCsvRow_has_no_EquipmentId_property()
|
||||
{
|
||||
// The CSV row shape mirrors the accepted columns. Keeping EquipmentId on the
|
||||
// row would invite the same misuse — drop it so the type system prevents
|
||||
// accidental population from a future column.
|
||||
var prop = typeof(EquipmentCsvRow).GetProperty("EquipmentId");
|
||||
prop.ShouldBeNull("Admin-012: EquipmentCsvRow must not expose an EquipmentId — the value is derived at finalise time.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_with_EquipmentId_column_is_rejected_as_unknown()
|
||||
{
|
||||
// After the fix, EquipmentId is an unknown column — the header validator must
|
||||
// refuse it like any other unrecognised column so operators get an explicit
|
||||
// error rather than silently importing the value.
|
||||
const string csv =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("EquipmentId",
|
||||
customMessage: "Importer must reject CSVs that still carry the (now disallowed) EquipmentId column.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_csv_without_EquipmentId_is_accepted()
|
||||
{
|
||||
// The canonical header should now omit EquipmentId.
|
||||
const string csv =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z-1,MC,SAP,11111111-2222-3333-4444-555555555555,Oven,Warsaw,Line1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
result.AcceptedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows.ShouldBeEmpty();
|
||||
result.AcceptedRows[0].ZTag.ShouldBe("z-1");
|
||||
result.AcceptedRows[0].EquipmentUuid.ShouldBe("11111111-2222-3333-4444-555555555555");
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,12 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
|
||||
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
|
||||
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
|
||||
// Admin-012: no EquipmentId on the CSV row — it is derived from EquipmentUuid at stage/finalise.
|
||||
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
|
||||
{
|
||||
ZTag = zTag,
|
||||
MachineCode = "mc",
|
||||
SAPID = $"sap-{zTag}",
|
||||
EquipmentId = "eq-id",
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
UnsAreaName = "area",
|
||||
@@ -189,7 +189,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
|
||||
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
|
||||
EquipmentUuid = sharedUuid.ToString(),
|
||||
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
|
||||
@@ -212,17 +212,18 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var rowA = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
|
||||
EquipmentId = "eq-a", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
||||
var rowBUuid = Guid.NewGuid();
|
||||
var rowB = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
|
||||
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
EquipmentUuid = rowBUuid.ToString(),
|
||||
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
|
||||
@@ -231,9 +232,9 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
|
||||
ex.Message.ShouldContain("z-collide");
|
||||
|
||||
// Second finalise must have rolled back — no partial Equipment row for batch B.
|
||||
// Second finalise must have rolled back — no partial Equipment row for batch B (match by UUID).
|
||||
var equipmentB = await _db.Equipment.AsNoTracking()
|
||||
.Where(e => e.EquipmentId == "eq-b")
|
||||
.Where(e => e.EquipmentUuid == rowBUuid)
|
||||
.ToListAsync();
|
||||
equipmentB.ShouldBeEmpty();
|
||||
}
|
||||
@@ -245,7 +246,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "", MachineCode = "mc", SAPID = "",
|
||||
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
|
||||
@@ -294,7 +295,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var conflictRow = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
|
||||
EquipmentId = "eq-x", EquipmentUuid = importerUuid.ToString(),
|
||||
EquipmentUuid = importerUuid.ToString(),
|
||||
Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
var cleanRow = Row("z-clean");
|
||||
@@ -334,7 +335,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var conflictRow = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
|
||||
EquipmentId = "eq-y", EquipmentUuid = importerUuid.ToString(),
|
||||
EquipmentUuid = importerUuid.ToString(),
|
||||
Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
|
||||
@@ -370,7 +371,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine",
|
||||
EquipmentId = "eq-z", EquipmentUuid = sharedUuid.ToString(), // same UUID
|
||||
EquipmentUuid = sharedUuid.ToString(), // same UUID
|
||||
Name = "z", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
|
||||
@@ -405,7 +406,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
|
||||
EquipmentId = "eq-new", EquipmentUuid = newImporterUuid.ToString(),
|
||||
EquipmentUuid = newImporterUuid.ToString(),
|
||||
Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Admin-011 — <see cref="FleetStatusPoller"/> kept three plain
|
||||
/// <c>Dictionary<,></c> caches that were enumerated/mutated from the steady-state
|
||||
/// poll loop and cleared from <c>ResetCache()</c> with no synchronization. A concurrent
|
||||
/// <c>ResetCache()</c> during a poll iteration could throw
|
||||
/// <see cref="InvalidOperationException"/> or corrupt the dictionary. The fix swaps the
|
||||
/// caches for <see cref="ConcurrentDictionary{TKey,TValue}"/> so reset + concurrent
|
||||
/// reads/writes are safe by construction.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FleetStatusPollerConcurrencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cache_fields_are_thread_safe_collections()
|
||||
{
|
||||
// The fix uses ConcurrentDictionary; that makes ResetCache() and concurrent
|
||||
// poll-tick mutations safe by construction. Guard the structural choice with
|
||||
// reflection so a future refactor cannot silently revert to plain Dictionary
|
||||
// without flipping this guardrail.
|
||||
var fields = typeof(FleetStatusPoller)
|
||||
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Where(f => f.Name is "_last" or "_lastRole" or "_lastResilience")
|
||||
.ToList();
|
||||
|
||||
fields.Count.ShouldBe(3, "expected the three cache fields _last/_lastRole/_lastResilience to exist");
|
||||
|
||||
foreach (var f in fields)
|
||||
{
|
||||
var type = f.FieldType;
|
||||
type.IsGenericType.ShouldBeTrue($"{f.Name} should be a generic concurrent collection");
|
||||
type.GetGenericTypeDefinition().ShouldBe(
|
||||
typeof(ConcurrentDictionary<,>),
|
||||
customMessage: $"{f.Name} must be a ConcurrentDictionary<,> so concurrent ResetCache()/poll calls are safe — plain Dictionary regressed Admin-011.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetCache_is_safe_to_call_concurrently_with_cache_mutations()
|
||||
{
|
||||
// Stress test — hammer the cache with mutate/clear concurrently. With plain
|
||||
// Dictionary this throws InvalidOperationException ("Collection was modified")
|
||||
// or corrupts internal state. With ConcurrentDictionary it must complete cleanly.
|
||||
var poller = BuildPollerForReflectionTest();
|
||||
|
||||
var lastField = typeof(FleetStatusPoller).GetField("_last", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var cache = lastField.GetValue(poller)!;
|
||||
var cacheType = cache.GetType();
|
||||
var indexer = cacheType.GetProperty("Item")!;
|
||||
|
||||
var keyType = cacheType.GetGenericArguments()[0]; // string
|
||||
var valueType = cacheType.GetGenericArguments()[1]; // NodeStateSnapshot record-struct
|
||||
var defaultSnapshot = Activator.CreateInstance(valueType)!;
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
|
||||
var writer = Task.Run(() =>
|
||||
{
|
||||
var i = 0;
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
indexer.SetValue(cache, defaultSnapshot, new object[] { $"node-{i++ % 64}" });
|
||||
}
|
||||
});
|
||||
var resetter = Task.Run(() =>
|
||||
{
|
||||
var method = typeof(FleetStatusPoller).GetMethod("ResetCache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
method.Invoke(poller, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Should not throw — the whole point is that the two run concurrently safely.
|
||||
Should.NotThrow(() => Task.WaitAll([writer, resetter]));
|
||||
}
|
||||
|
||||
private static FleetStatusPoller BuildPollerForReflectionTest()
|
||||
{
|
||||
// Pass null-style stubs — the poller constructor doesn't touch them and we
|
||||
// never call ExecuteAsync/PollOnceAsync here (those need a real DB context).
|
||||
// We only exercise ResetCache + cache mutation by reflection.
|
||||
var scopeFactory = new StubServiceScopeFactory();
|
||||
var fleetHub = new StubHubContext<FleetStatusHub>();
|
||||
var alertHub = new StubHubContext<AlertHub>();
|
||||
return new FleetStatusPoller(
|
||||
scopeFactory,
|
||||
fleetHub,
|
||||
alertHub,
|
||||
NullLogger<FleetStatusPoller>.Instance,
|
||||
new RedundancyMetrics());
|
||||
}
|
||||
|
||||
private sealed class StubServiceScopeFactory : IServiceScopeFactory
|
||||
{
|
||||
public IServiceScope CreateScope() => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class StubHubContext<THub> : IHubContext<THub> where THub : Hub
|
||||
{
|
||||
public IHubClients Clients => throw new NotImplementedException();
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user