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:
Joseph Doherty
2026-05-23 07:24:07 -04:00
parent 3f01a24b45
commit 2b33b64a58
16 changed files with 355 additions and 48 deletions

View File

@@ -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));
}
}

View File

@@ -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",

View File

@@ -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");
}
}

View File

@@ -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",
};

View File

@@ -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&lt;,&gt;</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();
}
}