using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// Verifies the static TransportGate single-flight guard (arch-review S4): /// transport bundle commands (export/preview/import) queue rather than run /// concurrently, because each import holds ~4 concurrent whole-artifact copies of /// the payload and two concurrent large imports on one central node are a /// realistic OOM path. The gate is exercised through the real import handler so /// the test tracks the actual concurrency shape of . /// public class TransportGateTests : TestKit, IDisposable { private readonly IBundleImporter _importer; private readonly ServiceCollection _services; public TransportGateTests() { _importer = Substitute.For(); _services = new ServiceCollection(); _services.AddScoped(_ => _importer); } private IActorRef CreateActor() { var sp = _services.BuildServiceProvider(); return Sys.ActorOf(Props.Create(() => new ManagementActor( sp, NullLogger.Instance))); } private static ManagementEnvelope Envelope(object command) => new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty()), command, Guid.NewGuid().ToString("N")); void IDisposable.Dispose() => Shutdown(); [Fact] public async Task ImportBundle_ConcurrentCommands_AreSingleFlighted() { // LoadAsync blocks on this TCS so the first import stays inside the gate. var release = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var loadStarted = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var loadCalls = 0; var session = new BundleSession { SessionId = Guid.NewGuid() }; _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => { Interlocked.Increment(ref loadCalls); loadStarted.TrySetResult(); return release.Task; }); _importer.PreviewAsync(Arg.Any(), Arg.Any()) .Returns(new ImportPreview(session.SessionId, Array.Empty())); _importer.ApplyAsync( Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ImportResult( Guid.NewGuid(), 0, 0, 0, 0, Array.Empty(), "corr")); var actor = CreateActor(); // A trivially-valid base64 payload; the substitute importer never parses it. var bundle = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }); actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip"))); actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip"))); // The first import must reach LoadAsync... await loadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); // ...and the second must be parked at the gate: give it ample time to // (wrongly) proceed, then assert it did not. await Task.Delay(750); Assert.Equal(1, Volatile.Read(ref loadCalls)); // Release the first; the second should now proceed and both should answer. release.SetResult(session); ExpectMsg(TimeSpan.FromSeconds(5)); ExpectMsg(TimeSpan.FromSeconds(5)); AwaitAssert(() => Assert.Equal(2, Volatile.Read(ref loadCalls)), TimeSpan.FromSeconds(5)); } }