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;
}
}