221 lines
7.5 KiB
C#
221 lines
7.5 KiB
C#
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
|
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
|
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
|
using GmRelay.Shared.Infrastructure.Scheduling;
|
|
using GmRelay.Shared.Platform;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace GmRelay.Bot.Tests.Infrastructure.Scheduling;
|
|
|
|
public sealed class SessionSchedulerServiceTests
|
|
{
|
|
private readonly FakeSystemClock _clock = new();
|
|
private readonly FakeSessionTriggerStore _store = new();
|
|
private readonly FakeSendConfirmationHandler _confirmationHandler = new();
|
|
private readonly FakeSendOneHourReminderHandler _oneHourHandler = new();
|
|
private readonly FakeSendJoinLinkHandler _joinLinkHandler = new();
|
|
|
|
private SessionSchedulerService CreateSut()
|
|
{
|
|
return new SessionSchedulerService(
|
|
_store,
|
|
_confirmationHandler,
|
|
_oneHourHandler,
|
|
_joinLinkHandler,
|
|
_clock,
|
|
NullLogger<SessionSchedulerService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenSessionNeedsConfirmation_CallsConfirmationHandler()
|
|
{
|
|
var sessionId = Guid.NewGuid();
|
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
_clock.UtcNow = now;
|
|
_store.SessionsNeedingConfirmation = [sessionId];
|
|
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Single(_confirmationHandler.Calls);
|
|
Assert.Equal(sessionId, _confirmationHandler.Calls[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenSessionNeedsOneHourReminder_CallsOneHourHandler()
|
|
{
|
|
var sessionId = Guid.NewGuid();
|
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
_clock.UtcNow = now;
|
|
_store.SessionsNeedingOneHourReminder = [sessionId];
|
|
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Single(_oneHourHandler.Calls);
|
|
Assert.Equal(sessionId, _oneHourHandler.Calls[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenSessionNeedsJoinLink_CallsJoinLinkHandler()
|
|
{
|
|
var sessionId = Guid.NewGuid();
|
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
_clock.UtcNow = now;
|
|
_store.SessionsNeedingJoinLink = [sessionId];
|
|
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Single(_joinLinkHandler.Calls);
|
|
Assert.Equal(sessionId, _joinLinkHandler.Calls[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenMultipleTriggers_AllHandlersCalled()
|
|
{
|
|
var confirmId = Guid.NewGuid();
|
|
var remindId = Guid.NewGuid();
|
|
var linkId = Guid.NewGuid();
|
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
_clock.UtcNow = now;
|
|
_store.SessionsNeedingConfirmation = [confirmId];
|
|
_store.SessionsNeedingOneHourReminder = [remindId];
|
|
_store.SessionsNeedingJoinLink = [linkId];
|
|
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Single(_confirmationHandler.Calls);
|
|
Assert.Single(_oneHourHandler.Calls);
|
|
Assert.Single(_joinLinkHandler.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenNoSessions_NothingCalled()
|
|
{
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Empty(_confirmationHandler.Calls);
|
|
Assert.Empty(_oneHourHandler.Calls);
|
|
Assert.Empty(_joinLinkHandler.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenConfirmationAlreadySent_DoesNotCallAgain()
|
|
{
|
|
var sessionId = Guid.NewGuid();
|
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
_clock.UtcNow = now;
|
|
_store.SessionsNeedingConfirmation = [sessionId];
|
|
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
Assert.Single(_confirmationHandler.Calls);
|
|
|
|
// Simulate idempotency: store no longer returns the session
|
|
_store.SessionsNeedingConfirmation = [];
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Single(_confirmationHandler.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenHandlerThrows_LogsErrorAndContinues()
|
|
{
|
|
var goodId = Guid.NewGuid();
|
|
var badId = Guid.NewGuid();
|
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
|
_clock.UtcNow = now;
|
|
_store.SessionsNeedingConfirmation = [badId, goodId];
|
|
_confirmationHandler.ThrowFor.Add(badId);
|
|
|
|
var sut = CreateSut();
|
|
await sut.TickAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
|
Assert.Equal(badId, _confirmationHandler.Calls[0]);
|
|
Assert.Equal(goodId, _confirmationHandler.Calls[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TickAsync_WhenStoreThrows_LogsErrorAndDoesNotCrash()
|
|
{
|
|
_store.ThrowOnConfirmationQuery = true;
|
|
var sut = CreateSut();
|
|
|
|
var ex = await Record.ExceptionAsync(() => sut.TickAsync(CancellationToken.None));
|
|
Assert.Null(ex);
|
|
}
|
|
|
|
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
|
|
{
|
|
public List<Guid> Calls { get; } = [];
|
|
public HashSet<Guid> ThrowFor { get; } = [];
|
|
|
|
public Task HandleAsync(Guid sessionId, CancellationToken ct)
|
|
{
|
|
Calls.Add(sessionId);
|
|
if (ThrowFor.Contains(sessionId))
|
|
throw new InvalidOperationException($"Boom for {sessionId}");
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSendOneHourReminderHandler : ISendOneHourReminderHandler
|
|
{
|
|
public List<Guid> Calls { get; } = [];
|
|
|
|
public Task HandleAsync(Guid sessionId, CancellationToken ct)
|
|
{
|
|
Calls.Add(sessionId);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSendJoinLinkHandler : ISendJoinLinkHandler
|
|
{
|
|
public List<Guid> Calls { get; } = [];
|
|
|
|
public Task HandleAsync(Guid sessionId, CancellationToken ct)
|
|
{
|
|
Calls.Add(sessionId);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSessionTriggerStore : ISessionTriggerStore
|
|
{
|
|
public List<Guid> SessionsNeedingConfirmation { get; set; } = [];
|
|
public List<Guid> SessionsNeedingOneHourReminder { get; set; } = [];
|
|
public List<Guid> SessionsNeedingJoinLink { get; set; } = [];
|
|
public bool ThrowOnConfirmationQuery { get; set; }
|
|
|
|
public Task<IReadOnlyList<Guid>> GetSessionsNeedingConfirmationAsync(
|
|
DateTimeOffset now, CancellationToken ct)
|
|
{
|
|
if (ThrowOnConfirmationQuery)
|
|
throw new InvalidOperationException("Store boom");
|
|
return Task.FromResult<IReadOnlyList<Guid>>(SessionsNeedingConfirmation);
|
|
}
|
|
|
|
public Task<IReadOnlyList<Guid>> GetSessionsNeedingOneHourReminderAsync(
|
|
DateTimeOffset now, CancellationToken ct)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<Guid>>(SessionsNeedingOneHourReminder);
|
|
}
|
|
|
|
public Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(
|
|
DateTimeOffset now, CancellationToken ct)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<Guid>>(SessionsNeedingJoinLink);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSystemClock : ISystemClock
|
|
{
|
|
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
|
|
}
|
|
}
|