diff --git a/windows/tests/Elevate.App.Tests/AppModelPropagationTests.cs b/windows/tests/Elevate.App.Tests/AppModelPropagationTests.cs index 319b3e8f..62d01a8d 100644 --- a/windows/tests/Elevate.App.Tests/AppModelPropagationTests.cs +++ b/windows/tests/Elevate.App.Tests/AppModelPropagationTests.cs @@ -24,16 +24,41 @@ private static ActivationOutcome Activated(RoleKey key, DateTimeOffset? started new(key, new ActivationResult.Activated(new ActiveAssignment( key, "a1", started ?? DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddHours(1), AssignmentStatus.Active))); - /// A model whose probe answers from 's claims, paced for a test. - private static async Task ModelAsync(string? token, RecordingNotifier? notifier = null) + /// + /// A model whose probe answers from 's claims. The pacing is a + /// millisecond so a test that waits for an answer gets one at once; pass + /// to hold the probe off instead, for a test about the state + /// before any probe has run. + /// + private static async Task ModelAsync( + string? token, RecordingNotifier? notifier = null, TimeSpan? probeAfter = null) { var tokens = new FakeTokenProvider { RefreshedToken = token }; var test = await TestModel.BootstrappedAsync(State(), online: true, tokens: tokens, notifier: notifier); - test.Model.PropagationFirstInterval = TimeSpan.FromMilliseconds(1); - test.Model.PropagationMaxInterval = TimeSpan.FromMilliseconds(1); + test.Model.PropagationFirstInterval = probeAfter ?? TimeSpan.FromMilliseconds(1); + test.Model.PropagationMaxInterval = probeAfter ?? TimeSpan.FromMilliseconds(1); return test; } + /// + /// Waits for rather than for a length of time. The model reports a + /// probe from a background task, so anything it triggers — a row changing, a notification — + /// lands a moment after the call that started it returns. + /// + private static async Task EventuallyAsync(Func condition, string what) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (!condition()) + { + if (DateTimeOffset.UtcNow > deadline) + { + throw new TimeoutException($"Timed out waiting for {what}."); + } + + await Task.Delay(5); + } + } + /// Waits for the watch on to settle, rather than for a fixed time. private static async Task SettledAsync(AppModel model, RoleKey key) { @@ -52,8 +77,8 @@ private static async Task SettledAsync(AppModel model, RoleKey key) [Fact] public async Task ARowIsPropagatingTheMomentItIsActivated() { - // No token to read, so the probe cannot confirm — but the row must say so before it asks. - using var test = await ModelAsync(token: null); + // The probe is held off, so this is the state WatchPropagation sets before asking anything. + using var test = await ModelAsync(token: null, probeAfter: TimeSpan.FromMinutes(5)); var key = EntraKey; test.Model.Active[key] = new ActiveAssignment(key, "a1", DateTimeOffset.UtcNow, null, AssignmentStatus.Active); @@ -80,6 +105,8 @@ public async Task AConfirmedProbeClearsTheRowAndNotifies() test.Model.Propagation[key].Should().Be(PropagationState.Ready); // Ready is recorded but says nothing on the row: the row is simply active again. test.Model.PropagationNote(key).Should().BeNull(); + // The notification is raised without being awaited, so it lands just after the state does. + await EventuallyAsync(() => notifier.Posted.Count > 0, "the ready notification"); notifier.Posted.Should().ContainSingle().Which.Title.Should().Contain("is ready"); } @@ -93,6 +120,8 @@ public async Task ARoleReadyStraightAwayIsNotWorthANotification() test.Model.WatchPropagation([Activated(key)]); await SettledAsync(test.Model, key); + // Nothing to wait for here, so give the notification every chance to appear and prove it does not. + await Task.Delay(100); test.Model.Propagation[key].Should().Be(PropagationState.Ready); notifier.Posted.Should().BeEmpty(); @@ -116,7 +145,7 @@ public async Task AnUnobservableRoleGoesBackToAPlainActiveRow() [Fact] public async Task DeactivatingWhileItPropagatesDropsTheWatch() { - using var test = await ModelAsync(token: null); + using var test = await ModelAsync(token: null, probeAfter: TimeSpan.FromMinutes(5)); var key = EntraKey; test.Model.Active[key] = new ActiveAssignment(key, "a1", DateTimeOffset.UtcNow, null, AssignmentStatus.Active); test.Model.WatchPropagation([Activated(key)]); diff --git a/windows/tests/Elevate.App.Tests/Support/TestModel.cs b/windows/tests/Elevate.App.Tests/Support/TestModel.cs index a042709a..540b1871 100644 --- a/windows/tests/Elevate.App.Tests/Support/TestModel.cs +++ b/windows/tests/Elevate.App.Tests/Support/TestModel.cs @@ -58,10 +58,19 @@ public Task AcquireInteractivelyAsync(Identity identity, string tenantId Inner.AcquireInteractivelyAsync(identity, tenantId, scopes, claims, ct); } -/// Records every notification the model posts. +/// +/// Records every notification the model posts. Guarded: a propagation probe reports from a +/// background task, so the list is appended from one thread while a test reads it from another. +/// public sealed class RecordingNotifier : IExpiryNotifier { - public List<(string Title, string Body)> Posted { get; } = []; + private readonly List<(string Title, string Body)> _posted = []; + private readonly Lock _gate = new(); + + public IReadOnlyList<(string Title, string Body)> Posted + { + get { lock (_gate) { return [.. _posted]; } } + } public Task RescheduleAsync( IReadOnlyList assignments, @@ -79,7 +88,11 @@ public Task SetPackageExpiriesAsync(IReadOnlyList expiries) public Task NotifyAsync(string title, string body) { - Posted.Add((title, body)); + lock (_gate) + { + _posted.Add((title, body)); + } + return Task.CompletedTask; } }