a5aed14dd2
- SessionSchedulerService now backs off for 15 minutes after any handler failure (confirmation, one-hour reminder, join link), preventing infinite retry loops on Discord 403 Missing Access. - Added per-session ConcurrentDictionary backoff tracking with automatic cleanup on success. - Enhanced DiscordPlatformMessenger logging for SendConfirmation and SendJoinLink to aid permission diagnostics. - Added 3 regression tests for backoff behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
283 lines
9.8 KiB
C#
283 lines
9.8 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);
|
|
}
|
|
|
|
[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<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;
|
|
}
|
|
}
|