using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Tests for — the notify-and-fetch central /// singleton that sweeps expired PendingDeployment staging rows. The actor is thin glue /// over IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync (whose /// row-level semantics are covered by the repository tests); these tests pin the actor's /// own policy: it opens a per-tick DI scope, calls the purge with a now-ish threshold, /// and — being best-effort hygiene — survives a throwing purge and keeps ticking. /// /// /// The substitute's return is driven by a closure with an /// counter rather than NSubstitute's ReceivedCalls(), because the repeating /// scheduler timer records calls on a background thread concurrently with the test /// thread's assertions — querying NSubstitute's call list under that concurrency can /// throw "collection was modified". A fenced counter is safe to read while the timer runs. /// public class PendingDeploymentPurgeActorTests : TestKit { private IActorRef CreateActor(IDeploymentManagerRepository repo, TimeSpan interval) { var services = new ServiceCollection(); // Mirror AddConfigurationDatabase: IDeploymentManagerRepository is scoped, so the // actor opens a fresh scope per tick and resolves the repository there. services.AddScoped(_ => repo); var sp = services.BuildServiceProvider(); var options = Options.Create(new CommunicationOptions { PendingDeploymentPurgeInterval = interval }); return Sys.ActorOf(Props.Create(() => new PendingDeploymentPurgeActor( sp, options, NullLogger.Instance))); } [Fact] public void Tick_PurgesExpiredRows_WithNowThreshold() { var calls = 0; DateTimeOffset captured = default; var repo = Substitute.For(); repo.PurgeExpiredPendingDeploymentsAsync(Arg.Any(), Arg.Any()) .Returns(ci => { captured = ci.Arg(); Interlocked.Increment(ref calls); return 3; }); // Large interval so the PreStart timer never fires during the test — only the // single manual tick below drives a purge, making the count deterministic. var actor = CreateActor(repo, TimeSpan.FromHours(1)); var before = DateTimeOffset.UtcNow; actor.Tell(PendingDeploymentPurgeActor.PurgeTick.Instance); AwaitAssert( () => Assert.Equal(1, Volatile.Read(ref calls)), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); // The purge threshold is "now" — bounded by the window around the tick. Reading // `captured` after the fenced counter read is safe (Interlocked establishes the // happens-before with the write that precedes the increment). var after = DateTimeOffset.UtcNow; Assert.InRange(captured, before, after); } [Fact] public void Tick_PurgeThrows_ActorSurvives_AndRetriesOnNextInterval() { var calls = 0; var repo = Substitute.For(); repo.PurgeExpiredPendingDeploymentsAsync(Arg.Any(), Arg.Any()) .Returns(_ => { Interlocked.Increment(ref calls); throw new InvalidOperationException("simulated purge failure"); }); // Fast interval so the self-scheduling timer fires repeatedly; the actor must // swallow each per-tick exception and keep ticking (best-effort hygiene — a // crash here would drop the singleton until failover). CreateActor(repo, TimeSpan.FromMilliseconds(100)); AwaitAssert( () => Assert.True(Volatile.Read(ref calls) >= 2, $"expected >= 2 ticks despite failures, got {Volatile.Read(ref calls)}"), duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50)); } }