forked from Manuel/meeting-assistant
199 lines
9.2 KiB
C#
199 lines
9.2 KiB
C#
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<DesktopUserRequestStore>.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<DesktopUserRequestStore>.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<DesktopUserRequestStore>.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<ManualTimer> 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; }
|
|
}
|
|
}
|