using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// /// Unit tests for — the same single-flight TTL contract as /// proves for the primitive, exercised here through the /// full DI-scope-per-flight shape: N concurrent callers before the evaluator resolves must /// share ONE fleet flatten, a value produced inside the TTL is served without re-evaluating, /// forceRefresh bypasses a fresh value but still joins an in-flight round, and a /// faulted evaluation is never memoized. /// public class PendingUpdatesCacheTests : IDisposable { private static readonly DateTimeOffset T0 = new(2026, 9, 14, 9, 0, 0, TimeSpan.Zero); private readonly IPendingUpdatesEvaluator _evaluator = Substitute.For(); private readonly ServiceProvider _provider; private readonly DeploymentStatusNotifier _statusNotifier = new(NullLogger.Instance); /// Initializes a service provider that resolves the substitute pending-updates evaluator. public PendingUpdatesCacheTests() { var services = new ServiceCollection(); services.AddScoped(_ => _evaluator); _provider = services.BuildServiceProvider(); } // A generous TTL (1h) so every test's timing is driven by task completion, not the clock. private static IOptions Options(TimeSpan? ttl = null) => Microsoft.Extensions.Options.Options.Create( new DeploymentManagerOptions { PendingUpdatesCacheTtl = ttl ?? TimeSpan.FromHours(1) }); private PendingUpdatesCache CreateSut(TimeSpan? ttl = null, TimeSpan? invalidationQuietPeriod = null) => new( _provider.GetRequiredService(), Options(ttl), _statusNotifier, invalidationQuietPeriod ?? TimeSpan.FromMilliseconds(20)); private void Finish(int instanceId, DeploymentStatus status) => _statusNotifier.NotifyStatusChanged(new DeploymentStatusChange($"dep-{instanceId}", instanceId, status)); private static PendingUpdatesSnapshot Snapshot() => new(T0, Array.Empty()); /// Verifies that concurrent callers share a single evaluation. /// A task that represents the asynchronous operation. [Fact] public async Task ConcurrentCallers_ShareOneEvaluation() { var released = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); _evaluator.EvaluateAsync(Arg.Any()).Returns(released.Task); var sut = CreateSut(); var waiters = Enumerable.Range(0, 10).Select(_ => sut.GetAsync()).ToArray(); released.SetResult(Snapshot()); var results = await Task.WhenAll(waiters); await _evaluator.Received(1).EvaluateAsync(Arg.Any()); Assert.All(results, r => Assert.Same(results[0], r)); } /// Verifies that a call within the TTL serves the memoized value without re-evaluating. /// A task that represents the asynchronous operation. [Fact] public async Task WithinTtl_ServesTheMemoizedValue_WithoutReEvaluating() { _evaluator.EvaluateAsync(Arg.Any()).Returns(Snapshot()); var sut = CreateSut(); var first = await sut.GetAsync(); var second = await sut.GetAsync(); await _evaluator.Received(1).EvaluateAsync(Arg.Any()); Assert.Same(first, second); } /// Verifies that a forced refresh bypasses a still-fresh cached value. /// A task that represents the asynchronous operation. [Fact] public async Task ForceRefresh_BypassesAFreshValue() { var snapshotA = Snapshot(); var snapshotB = new PendingUpdatesSnapshot(T0.AddMinutes(1), Array.Empty()); _evaluator.EvaluateAsync(Arg.Any()).Returns(snapshotA, snapshotB); var sut = CreateSut(); var first = await sut.GetAsync(); var forced = await sut.GetAsync(forceRefresh: true); await _evaluator.Received(2).EvaluateAsync(Arg.Any()); Assert.Same(snapshotA, first); Assert.Same(snapshotB, forced); } /// Verifies that a forced refresh still joins an evaluation that is already in flight. /// A task that represents the asynchronous operation. [Fact] public async Task ForceRefresh_StillJoinsAnInFlightRound() { var released = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); _evaluator.EvaluateAsync(Arg.Any()).Returns(released.Task); var sut = CreateSut(); var first = sut.GetAsync(); var forcedA = sut.GetAsync(forceRefresh: true); var forcedB = sut.GetAsync(forceRefresh: true); var snapshot = Snapshot(); released.SetResult(snapshot); Assert.Same(snapshot, await first); Assert.Same(snapshot, await forcedA); Assert.Same(snapshot, await forcedB); await _evaluator.Received(1).EvaluateAsync(Arg.Any()); } /// Verifies that a failed evaluation surfaces to the caller and is remembered only for the short back-off (review 2026-10-21 04/C4). /// A task that represents the asynchronous operation. [Fact] public async Task FailedEvaluation_SurfacesToTheCaller_AndIsBackedOffNotRetriedPerPoll() { _evaluator.EvaluateAsync(Arg.Any()) .Returns( Task.FromException(new InvalidOperationException("flatten failed")), Task.FromResult(Snapshot())); var sut = CreateSut(); await Assert.ThrowsAsync(() => sut.GetAsync()); // Inside the negative TTL a poll is served the same fault with no new flatten. await Assert.ThrowsAsync(() => sut.GetAsync()); await _evaluator.Received(1).EvaluateAsync(Arg.Any()); // An operator Re-check bypasses the back-off. var recovered = await sut.GetAsync(forceRefresh: true); Assert.NotNull(recovered); await _evaluator.Received(2).EvaluateAsync(Arg.Any()); } /// /// Verifies that a finished deployment drops the memoized snapshot — a deploy is exactly the /// event that changes the answer, so serving the pre-deploy count for the rest of the TTL /// window left the rail showing stale "pending" counts after a successful Deploy all. /// /// A task that represents the asynchronous operation. [Theory] [InlineData(DeploymentStatus.Success)] [InlineData(DeploymentStatus.Failed)] public async Task FinishedDeployment_InvalidatesTheMemoizedSnapshot(DeploymentStatus status) { _evaluator.EvaluateAsync(Arg.Any()).Returns(_ => Snapshot()); using var sut = CreateSut(); await sut.GetAsync(); Finish(1, status); await sut.GetAsync(); await _evaluator.Received(2).EvaluateAsync(Arg.Any()); } /// Verifies that a deployment that has only started does not invalidate the snapshot. /// A task that represents the asynchronous operation. [Fact] public async Task StartedDeployment_DoesNotInvalidate() { _evaluator.EvaluateAsync(Arg.Any()).Returns(_ => Snapshot()); using var sut = CreateSut(); await sut.GetAsync(); Finish(1, DeploymentStatus.InProgress); await sut.GetAsync(); await _evaluator.Received(1).EvaluateAsync(Arg.Any()); } /// /// Verifies that a burst of completions — one per instance in a bulk deploy — raises /// Invalidated once, after the burst goes quiet, so every open rail re-reads once /// instead of once per instance. /// /// A task that represents the asynchronous operation. [Fact] public async Task BurstOfFinishedDeployments_RaisesInvalidatedOnceAfterItGoesQuiet() { using var sut = CreateSut(invalidationQuietPeriod: TimeSpan.FromMilliseconds(100)); var raised = 0; var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); sut.Invalidated += () => { Interlocked.Increment(ref raised); signal.TrySetResult(); }; for (var i = 1; i <= 9; i++) { Finish(i, DeploymentStatus.Success); } await signal.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Task.Delay(300); Assert.Equal(1, Volatile.Read(ref raised)); } /// Verifies that the cache stops listening to deployment status changes once disposed. /// A task that represents the asynchronous operation. [Fact] public async Task Dispose_UnsubscribesFromStatusChanges() { var sut = CreateSut(); var raised = 0; sut.Invalidated += () => Interlocked.Increment(ref raised); sut.Dispose(); Finish(1, DeploymentStatus.Success); await Task.Delay(150); Assert.Equal(0, Volatile.Read(ref raised)); } /// Releases the resources held by the test fixture. public void Dispose() => _provider.Dispose(); }