using MeetingAssistant.Notifications; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; namespace MeetingAssistant.Tests; public sealed class DesktopUserRequestTests { [Fact] public async Task OfferedDecisionIsListedAndDeliveredOnce() { using var lifetime = new TestApplicationLifetime(); using var store = CreateStore(lifetime); var registration = store.Add(Approval(), CancellationToken.None); var request = Assert.Single(store.List()); Assert.Equal(registration.Id, request.Id); Assert.Equal("Associate Alpha?", request.Title); Assert.Equal(["yes", "no"], request.Actions.Select(action => action.Id)); Assert.False(registration.Decision.IsCompleted); Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(request.Id, "yes")); Assert.Equal("yes", await registration.Decision); Assert.Empty(store.List()); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(request.Id, "yes")); } [Fact] public async Task UnsupportedActionLeavesDecisionPending() { using var lifetime = new TestApplicationLifetime(); using var store = CreateStore(lifetime); var registration = store.Add(Approval(), CancellationToken.None); Assert.Equal(DesktopUserRequestRespondResult.InvalidAction, await store.RespondAsync(registration.Id, "delete")); Assert.Single(store.List()); Assert.False(registration.Decision.IsCompleted); Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(registration.Id, "no")); Assert.Equal("no", await registration.Decision); } [Fact] public async Task CanceledOriginRemovesDecisionAndRejectsLateResponse() { using var lifetime = new TestApplicationLifetime(); using var store = CreateStore(lifetime); using var origin = new CancellationTokenSource(); var invoked = false; var registration = store.Add(Approval(), origin.Token, (_, _) => { invoked = true; return Task.CompletedTask; }); await origin.CancelAsync(); Assert.Empty(store.List()); Assert.Null(await registration.Decision.WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes")); Assert.False(invoked); } [Fact] public async Task DeadlineResolvesUnansweredDecisionWithoutPolling() { using var lifetime = new TestApplicationLifetime(); var clock = new ManualDesktopRequestTimeProvider(); using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger.Instance); var registration = store.Add(Approval(), CancellationToken.None); Assert.Equal(DateTimeOffset.Parse("2026-10-03T10:10:00Z"), Assert.Single(store.List()).ExpiresAt); clock.Advance(TimeSpan.FromMinutes(10)); Assert.True(registration.Decision.IsCompleted); Assert.Null(await registration.Decision); Assert.Empty(store.List()); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes")); } [Fact] public async Task KindWithdrawalPreservesOtherQuestionsAndShutdownRemovesThem() { using var lifetime = new TestApplicationLifetime(); using var store = CreateStore(lifetime); var approval = store.Add(Approval(), CancellationToken.None); var warning = store.Add(Approval() with { Kind = "meeting-inactivity" }, CancellationToken.None); store.DismissKind("meeting-inactivity"); Assert.Equal(approval.Id, Assert.Single(store.List()).Id); Assert.Null(await warning.Decision.WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(warning.Id, "yes")); lifetime.StopApplication(); Assert.Empty(store.List()); Assert.Null(await approval.Decision.WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(approval.Id, "yes")); } [Fact] public async Task FailedActionIsConsumedAndReportedWithoutReplayingWork() { using var lifetime = new TestApplicationLifetime(); using var store = CreateStore(lifetime); var registration = store.Add(Approval(), CancellationToken.None, (_, _) => throw new InvalidOperationException("Test action could not finish.")); Assert.Equal(DesktopUserRequestRespondResult.Failed, await store.RespondAsync(registration.Id, "yes")); Assert.Null(await registration.Decision); Assert.Empty(store.List()); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes")); } [Fact] public async Task PendingListExcludesDeadlinesBeforeTimerDispatch() { using var lifetime = new TestApplicationLifetime(); var clock = new ManualDesktopRequestTimeProvider(); using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger.Instance); var registration = store.Add(Approval(), CancellationToken.None); clock.Advance(TimeSpan.FromMinutes(10), fireTimers: false); Assert.Empty(store.List()); Assert.Null(await registration.Decision); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes")); } [Fact] public async Task SimultaneousPresentationsUseTheOriginLifetimeAndPerformOneAction() { using var lifetime = new TestApplicationLifetime(); using var origin = new CancellationTokenSource(); using var store = CreateStore(lifetime); var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var actionCount = 0; CancellationToken observedToken = default; var registration = store.Add(Approval(), origin.Token, async (_, token) => { Interlocked.Increment(ref actionCount); observedToken = token; started.TrySetResult(); await finish.Task.WaitAsync(token); }); var firstResponse = store.RespondAsync(registration.Id, "yes"); await started.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "no")); Assert.Equal(origin.Token, observedToken); finish.SetResult(); Assert.Equal(DesktopUserRequestRespondResult.Accepted, await firstResponse); Assert.Equal("yes", await registration.Decision); Assert.Equal(1, actionCount); } private static DesktopUserRequestSpec Approval() => new( "agent-approval", "Associate Alpha?", "The meeting discusses Alpha.", TimeSpan.FromMinutes(10), [new("yes", "Yes"), new("no", "No")]); private static DesktopUserRequestStore CreateStore(TestApplicationLifetime lifetime) => new( TimeProvider.System, lifetime, NullLogger.Instance); } internal sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable { private readonly CancellationTokenSource stopping = new(); public CancellationToken ApplicationStarted => CancellationToken.None; public CancellationToken ApplicationStopping => stopping.Token; public CancellationToken ApplicationStopped => stopping.Token; public void StopApplication() => stopping.Cancel(); public void Dispose() => stopping.Dispose(); } internal sealed class ManualDesktopRequestTimeProvider : TimeProvider { private DateTimeOffset now = DateTimeOffset.Parse("2026-10-03T10:00:00Z"); private readonly List timers = []; public override DateTimeOffset GetUtcNow() => now; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = new ManualTimer(this, callback, state); timers.Add(timer); timer.Change(dueTime, period); return timer; } public void Advance(TimeSpan elapsed, bool fireTimers = true) { now += elapsed; if (!fireTimers) return; foreach (var timer in timers.ToArray()) timer.FireIfDue(); } private sealed class ManualTimer(ManualDesktopRequestTimeProvider owner, TimerCallback callback, object? state) : ITimer { private DateTimeOffset due; private bool disposed; public bool Change(TimeSpan dueTime, TimeSpan period) { due = dueTime == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : owner.now + dueTime; return !disposed; } public void FireIfDue() { if (disposed || due > owner.now) return; due = DateTimeOffset.MaxValue; callback(state); } public void Dispose() => disposed = true; public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } } }