Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerConcurrencyTests.cs
Joseph Doherty 2b33b64a58 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>
2026-05-23 07:24:07 -04:00

116 lines
4.9 KiB
C#

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