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.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); } [Fact] public async Task TickAsync_WhenHandlerThrows_BackoffsForDuration() { var sessionId = Guid.NewGuid(); var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero); _clock.UtcNow = now; _store.SessionsNeedingConfirmation = [sessionId]; _confirmationHandler.ThrowFor.Add(sessionId); var sut = CreateSut(); await sut.TickAsync(CancellationToken.None); Assert.Single(_confirmationHandler.Calls); // Second tick immediately — should be backed off await sut.TickAsync(CancellationToken.None); Assert.Single(_confirmationHandler.Calls); } [Fact] public async Task TickAsync_WhenHandlerThrows_AfterBackoffRetriesAgain() { var sessionId = Guid.NewGuid(); var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero); _clock.UtcNow = now; _store.SessionsNeedingConfirmation = [sessionId]; _confirmationHandler.ThrowFor.Add(sessionId); var sut = CreateSut(); await sut.TickAsync(CancellationToken.None); Assert.Single(_confirmationHandler.Calls); // Advance clock past backoff duration (15 min) _clock.UtcNow = now.AddMinutes(16); await sut.TickAsync(CancellationToken.None); Assert.Equal(2, _confirmationHandler.Calls.Count); } [Fact] public async Task TickAsync_WhenHandlerSucceedsAfterBackoff_ClearsBackoff() { var sessionId = Guid.NewGuid(); var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero); _clock.UtcNow = now; _store.SessionsNeedingConfirmation = [sessionId]; _confirmationHandler.ThrowFor.Add(sessionId); var sut = CreateSut(); await sut.TickAsync(CancellationToken.None); Assert.Single(_confirmationHandler.Calls); // Remove throw condition, advance past backoff _confirmationHandler.ThrowFor.Remove(sessionId); _clock.UtcNow = now.AddMinutes(16); await sut.TickAsync(CancellationToken.None); Assert.Equal(2, _confirmationHandler.Calls.Count); // Next tick should still call because backoff was cleared on success _clock.UtcNow = now.AddMinutes(17); await sut.TickAsync(CancellationToken.None); Assert.Equal(3, _confirmationHandler.Calls.Count); } private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler { public List Calls { get; } = []; public HashSet 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 Calls { get; } = []; public Task HandleAsync(Guid sessionId, CancellationToken ct) { Calls.Add(sessionId); return Task.CompletedTask; } } private sealed class FakeSendJoinLinkHandler : ISendJoinLinkHandler { public List Calls { get; } = []; public Task HandleAsync(Guid sessionId, CancellationToken ct) { Calls.Add(sessionId); return Task.CompletedTask; } } private sealed class FakeSessionTriggerStore : ISessionTriggerStore { public List SessionsNeedingConfirmation { get; set; } = []; public List SessionsNeedingOneHourReminder { get; set; } = []; public List SessionsNeedingJoinLink { get; set; } = []; public bool ThrowOnConfirmationQuery { get; set; } public Task> GetSessionsNeedingConfirmationAsync( DateTimeOffset now, CancellationToken ct) { if (ThrowOnConfirmationQuery) throw new InvalidOperationException("Store boom"); return Task.FromResult>(SessionsNeedingConfirmation); } public Task> GetSessionsNeedingOneHourReminderAsync( DateTimeOffset now, CancellationToken ct) { return Task.FromResult>(SessionsNeedingOneHourReminder); } public Task> GetSessionsNeedingJoinLinkAsync( DateTimeOffset now, CancellationToken ct) { return Task.FromResult>(SessionsNeedingJoinLink); } } private sealed class FakeSystemClock : ISystemClock { public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow; } }