Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions windows/tests/Elevate.App.Tests/AppModelPropagationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

/// <summary>A model whose probe answers from <paramref name="token"/>'s claims, paced for a test.</summary>
private static async Task<TestModel> ModelAsync(string? token, RecordingNotifier? notifier = null)
/// <summary>
/// A model whose probe answers from <paramref name="token"/>'s claims. The pacing is a
/// millisecond so a test that waits for an answer gets one at once; pass
/// <paramref name="probeAfter"/> to hold the probe off instead, for a test about the state
/// before any probe has run.
/// </summary>
private static async Task<TestModel> 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;
}

/// <summary>
/// Waits for <paramref name="condition"/> 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.
/// </summary>
private static async Task EventuallyAsync(Func<bool> 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);
}
}

/// <summary>Waits for the watch on <paramref name="key"/> to settle, rather than for a fixed time.</summary>
private static async Task SettledAsync(AppModel model, RoleKey key)
{
Expand All @@ -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);

Expand All @@ -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");
}

Expand All @@ -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();
Expand All @@ -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)]);
Expand Down
19 changes: 16 additions & 3 deletions windows/tests/Elevate.App.Tests/Support/TestModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,19 @@ public Task<string> AcquireInteractivelyAsync(Identity identity, string tenantId
Inner.AcquireInteractivelyAsync(identity, tenantId, scopes, claims, ct);
}

/// <summary>Records every notification the model posts.</summary>
/// <summary>
/// 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.
/// </summary>
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<ActiveAssignment> assignments,
Expand All @@ -79,7 +88,11 @@ public Task SetPackageExpiriesAsync(IReadOnlyList<PackageExpiry> expiries)

public Task NotifyAsync(string title, string body)
{
Posted.Add((title, body));
lock (_gate)
{
_posted.Add((title, body));
}

return Task.CompletedTask;
}
}
Expand Down
Loading