using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; 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; /// 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) => new(_provider.GetRequiredService(), Options(ttl)); 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 not memoized. /// A task that represents the asynchronous operation. [Fact] public async Task FailedEvaluation_SurfacesToTheCaller_AndIsNotMemoized() { _evaluator.EvaluateAsync(Arg.Any()) .Returns( Task.FromException(new InvalidOperationException("flatten failed")), Task.FromResult(Snapshot())); var sut = CreateSut(); await Assert.ThrowsAsync(() => sut.GetAsync()); // Same instant, well inside the TTL — a cached fault would starve every dashboard // for the rest of the window, so the next caller must start a new evaluation. var recovered = await sut.GetAsync(); Assert.NotNull(recovered); await _evaluator.Received(2).EvaluateAsync(Arg.Any()); } /// Releases the resources held by the test fixture. public void Dispose() => _provider.Dispose(); }