Files
GmRelayBot/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs
T
Toutsu 2a707e4825
PR Checks / test-and-build (pull_request) Successful in 7m9s
feat(platform): route scheduler notifications through platform messenger
2026-05-21 12:30:35 +03:00

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