From e6e6d17b7213611583896ae58b6e4e82be63a95f Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 11 May 2026 13:38:34 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(#20):=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20RSVP=20=D0=B8=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BD=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена абстракция ISystemClock + SystemClock / FakeSystemClock для тестируемого scheduling. - Добавлена миграция V014: confirmation_sent_at в sessions. - Обновлен SendConfirmationHandler: записывает confirmation_sent_at. - Обновлен SessionSchedulerService: - выделен ISessionTriggerStore / DbSessionTriggerStore - SQL-запросы используют параметр @Now вместо now() - добавлен публичный TickAsync для тестов - защита от дублей через confirmation_sent_at IS NULL - Обновлен RescheduleVotingDeadlineService: использует ISystemClock. - Добавлены интерфейсы ISendConfirmationHandler, ISendOneHourReminderHandler, ISendJoinLinkHandler для unit-тестируемости. - Добавлены 8 unit-тестов SessionSchedulerService: - все 3 триггера (T-24h, T-1h, T-5min) - идемпотентность при повторном запуске - ошибки handler не падают и не блокируют другие сессии - ошибки store логируются без падения worker-а Bump version -> 1.13.0 Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 4 +- .../ISendConfirmationHandler.cs | 6 + .../SendConfirmationHandler.cs | 5 +- .../SendJoinLink/ISendJoinLinkHandler.cs | 6 + .../SendJoinLink/SendJoinLinkHandler.cs | 2 +- .../ISendOneHourReminderHandler.cs | 6 + .../SendOneHourReminderHandler.cs | 2 +- .../RescheduleVotingDeadlineService.cs | 11 +- .../Scheduling/ISessionTriggerStore.cs | 86 +++++++ .../Infrastructure/Scheduling/ISystemClock.cs | 16 ++ .../Scheduling/SessionSchedulerService.cs | 138 ++++++----- .../V014__add_confirmation_sent_at.sql | 13 ++ src/GmRelay.Bot/Program.cs | 7 + .../Components/Layout/NavMenu.razor | 2 +- .../SessionSchedulerServiceTests.cs | 214 ++++++++++++++++++ 17 files changed, 434 insertions(+), 88 deletions(-) create mode 100644 src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs create mode 100644 src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs create mode 100644 src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs create mode 100644 src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs create mode 100644 src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs create mode 100644 src/GmRelay.Bot/Migrations/V014__add_confirmation_sent_at.sql create mode 100644 tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fc15cf8..ff9e9e4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.12.0 + VERSION: 1.13.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 61e823c..efc3923 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.12.0 + 1.13.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index b49bc01..a21fd8c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.12.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.13.0 restart: always depends_on: db: @@ -30,7 +30,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.12.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.13.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs new file mode 100644 index 0000000..ce46721 --- /dev/null +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs @@ -0,0 +1,6 @@ +namespace GmRelay.Bot.Features.Confirmation.SendConfirmation; + +public interface ISendConfirmationHandler +{ + Task HandleAsync(Guid sessionId, CancellationToken ct); +} diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs index a47e98e..f69011a 100644 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -32,7 +32,7 @@ public sealed class SendConfirmationHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, DirectSessionNotificationSender directSender, - ILogger logger) + ILogger logger) : ISendConfirmationHandler { public async Task HandleAsync(Guid sessionId, CancellationToken ct) { @@ -103,12 +103,13 @@ public sealed class SendConfirmationHandler( replyMarkup: keyboard, cancellationToken: ct); - // 5. Update session status and store message ID + // 5. Update session status, store message ID, and mark confirmation sent await connection.ExecuteAsync( """ UPDATE sessions SET status = @Status, confirmation_message_id = @MessageId, + confirmation_sent_at = now(), updated_at = now() WHERE id = @SessionId """, diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs new file mode 100644 index 0000000..d81887b --- /dev/null +++ b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs @@ -0,0 +1,6 @@ +namespace GmRelay.Bot.Features.Reminders.SendJoinLink; + +public interface ISendJoinLinkHandler +{ + Task HandleAsync(Guid sessionId, CancellationToken ct); +} diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs index 2e5eb46..261b041 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -31,7 +31,7 @@ public sealed class SendJoinLinkHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, DirectSessionNotificationSender directSender, - ILogger logger) + ILogger logger) : ISendJoinLinkHandler { public async Task HandleAsync(Guid sessionId, CancellationToken ct) { diff --git a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs new file mode 100644 index 0000000..21564d6 --- /dev/null +++ b/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs @@ -0,0 +1,6 @@ +namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder; + +public interface ISendOneHourReminderHandler +{ + Task HandleAsync(Guid sessionId, CancellationToken ct); +} diff --git a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs index 6255d19..474b72a 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs +++ b/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs @@ -15,7 +15,7 @@ internal sealed record OneHourReminderSession( public sealed class SendOneHourReminderHandler( NpgsqlDataSource dataSource, DirectSessionNotificationSender directSender, - ILogger logger) + ILogger logger) : ISendOneHourReminderHandler { public async Task HandleAsync(Guid sessionId, CancellationToken ct) { diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 1a498ce..046b126 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -1,5 +1,6 @@ using Dapper; using GmRelay.Bot.Features.Notifications; +using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; @@ -25,6 +26,7 @@ public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, ITelegramBotClient bot, DirectSessionNotificationSender directSender, + ISystemClock clock, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -55,10 +57,11 @@ public sealed class RescheduleVotingDeadlineService( FROM reschedule_proposals WHERE status = 'Voting' AND voting_deadline_at IS NOT NULL - AND voting_deadline_at <= now() + AND voting_deadline_at <= @Now ORDER BY voting_deadline_at LIMIT 25 - """)).ToList(); + """, + new { Now = clock.UtcNow.UtcDateTime })).ToList(); foreach (var proposalId in proposalIds) { @@ -97,10 +100,10 @@ public sealed class RescheduleVotingDeadlineService( WHERE rp.id = @ProposalId AND rp.status = 'Voting' AND rp.voting_deadline_at IS NOT NULL - AND rp.voting_deadline_at <= now() + AND rp.voting_deadline_at <= @Now FOR UPDATE """, - new { ProposalId = proposalId }, + new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, transaction); if (proposal is null) diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs new file mode 100644 index 0000000..fe2b0a8 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs @@ -0,0 +1,86 @@ +using Dapper; +using GmRelay.Shared.Domain; +using Npgsql; + +namespace GmRelay.Bot.Infrastructure.Scheduling; + +public interface ISessionTriggerStore +{ + Task> GetSessionsNeedingConfirmationAsync(DateTimeOffset now, CancellationToken ct); + Task> GetSessionsNeedingOneHourReminderAsync(DateTimeOffset now, CancellationToken ct); + Task> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct); +} + +public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessionTriggerStore +{ + private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24); + private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1); + private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5); + + public async Task> GetSessionsNeedingConfirmationAsync(DateTimeOffset now, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var results = await connection.QueryAsync( + """ + SELECT id + FROM sessions + WHERE status = @Planned + AND scheduled_at - @LeadTime <= @Now + AND confirmation_sent_at IS NULL + """, + new + { + Planned = SessionStatus.Planned, + LeadTime = ConfirmationLeadTime, + Now = now.UtcDateTime + }); + + return results.ToList(); + } + + public async Task> GetSessionsNeedingOneHourReminderAsync(DateTimeOffset now, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var results = await connection.QueryAsync( + """ + SELECT id + FROM sessions + WHERE status IN (@Confirmed, @ConfirmationSent) + AND scheduled_at - @LeadTime <= @Now + AND one_hour_reminder_processed_at IS NULL + """, + new + { + Confirmed = SessionStatus.Confirmed, + ConfirmationSent = SessionStatus.ConfirmationSent, + LeadTime = OneHourReminderLeadTime, + Now = now.UtcDateTime + }); + + return results.ToList(); + } + + public async Task> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var results = await connection.QueryAsync( + """ + SELECT id + FROM sessions + WHERE status = @Confirmed + AND scheduled_at - @LeadTime <= @Now + AND link_message_id IS NULL + """, + new + { + Confirmed = SessionStatus.Confirmed, + LeadTime = JoinLinkLeadTime, + Now = now.UtcDateTime + }); + + return results.ToList(); + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs new file mode 100644 index 0000000..46e0888 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs @@ -0,0 +1,16 @@ +namespace GmRelay.Bot.Infrastructure.Scheduling; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} + +public sealed class SystemClock : ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} + +public sealed class FakeSystemClock : ISystemClock +{ + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs index e3c2a67..31b227a 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs @@ -1,31 +1,27 @@ -using Dapper; -using GmRelay.Shared.Domain; using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendOneHourReminder; -using Npgsql; namespace GmRelay.Bot.Infrastructure.Scheduling; /// /// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions. -/// Two triggers: +/// Three triggers: /// T-24h: send confirmation request with inline keyboard +/// T-1h: send one-hour direct reminder /// T-5min: send join link to all confirmed players /// /// If the Raspberry Pi reboots, nothing is lost — all state is in the DB. /// public sealed class SessionSchedulerService( - NpgsqlDataSource dataSource, - SendConfirmationHandler confirmationHandler, - SendOneHourReminderHandler oneHourReminderHandler, - SendJoinLinkHandler joinLinkHandler, + ISessionTriggerStore triggerStore, + ISendConfirmationHandler confirmationHandler, + ISendOneHourReminderHandler oneHourReminderHandler, + ISendJoinLinkHandler joinLinkHandler, + ISystemClock clock, ILogger logger) : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); - private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24); - private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1); - private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -33,14 +29,11 @@ public sealed class SessionSchedulerService( using var timer = new PeriodicTimer(TickInterval); - // Run immediately on startup, then on each tick do { try { - await ProcessConfirmationTriggers(stoppingToken); - await ProcessOneHourReminderTriggers(stoppingToken); - await ProcessJoinLinkTriggers(stoppingToken); + await TickAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -57,57 +50,30 @@ public sealed class SessionSchedulerService( } /// - /// T-1h trigger: process direct reminders according to the session notification mode. + /// Runs a single scheduler tick using the current clock time. + /// Public so it can be called from integration tests with a fake clock. /// - private async Task ProcessOneHourReminderTriggers(CancellationToken ct) + public async Task TickAsync(CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); + var now = clock.UtcNow; - var sessionIds = await connection.QueryAsync( - """ - SELECT id - FROM sessions - WHERE status IN (@Confirmed, @ConfirmationSent) - AND scheduled_at - @LeadTime <= now() - AND one_hour_reminder_processed_at IS NULL - """, - new - { - Confirmed = SessionStatus.Confirmed, - ConfirmationSent = SessionStatus.ConfirmationSent, - LeadTime = OneHourReminderLeadTime - }); - - foreach (var sessionId in sessionIds) - { - try - { - await oneHourReminderHandler.HandleAsync(sessionId, ct); - logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId); - } - } + await ProcessConfirmationTriggers(now, ct); + await ProcessOneHourReminderTriggers(now, ct); + await ProcessJoinLinkTriggers(now, ct); } - /// - /// T-24h trigger: find sessions that need confirmation requests sent. - /// Condition: status='Planned' AND scheduled_at minus 24h is in the past. - /// - private async Task ProcessConfirmationTriggers(CancellationToken ct) + private async Task ProcessConfirmationTriggers(DateTimeOffset now, CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); - - var sessionIds = await connection.QueryAsync( - """ - SELECT id - FROM sessions - WHERE status = @Planned - AND scheduled_at - @LeadTime <= now() - """, - new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime }); + IReadOnlyList sessionIds; + try + { + sessionIds = await triggerStore.GetSessionsNeedingConfirmationAsync(now, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to query confirmation triggers"); + return; + } foreach (var sessionId in sessionIds) { @@ -123,23 +89,45 @@ public sealed class SessionSchedulerService( } } - /// - /// T-5min trigger: find confirmed sessions that need join links sent. - /// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent. - /// - private async Task ProcessJoinLinkTriggers(CancellationToken ct) + private async Task ProcessOneHourReminderTriggers(DateTimeOffset now, CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); + IReadOnlyList sessionIds; + try + { + sessionIds = await triggerStore.GetSessionsNeedingOneHourReminderAsync(now, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to query one-hour reminder triggers"); + return; + } - var sessionIds = await connection.QueryAsync( - """ - SELECT id - FROM sessions - WHERE status = @Confirmed - AND scheduled_at - @LeadTime <= now() - AND link_message_id IS NULL - """, - new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime }); + foreach (var sessionId in sessionIds) + { + try + { + await oneHourReminderHandler.HandleAsync(sessionId, ct); + logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId); + } + } + } + + private async Task ProcessJoinLinkTriggers(DateTimeOffset now, CancellationToken ct) + { + IReadOnlyList sessionIds; + try + { + sessionIds = await triggerStore.GetSessionsNeedingJoinLinkAsync(now, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to query join-link triggers"); + return; + } foreach (var sessionId in sessionIds) { diff --git a/src/GmRelay.Bot/Migrations/V014__add_confirmation_sent_at.sql b/src/GmRelay.Bot/Migrations/V014__add_confirmation_sent_at.sql new file mode 100644 index 0000000..5b4415e --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V014__add_confirmation_sent_at.sql @@ -0,0 +1,13 @@ +ALTER TABLE sessions + ADD COLUMN confirmation_sent_at TIMESTAMPTZ; + +-- Update existing ConfirmationSent sessions to have a sentinel value +-- so they don't get re-processed after migration +UPDATE sessions +SET confirmation_sent_at = now() +WHERE status = 'ConfirmationSent'; + +-- Partial index for efficient T-24h query +CREATE INDEX ix_sessions_confirmation_reminders ON sessions (scheduled_at) + WHERE status = 'Planned' + AND confirmation_sent_at IS NULL; diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index d28aea6..e8ee6ea 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -52,10 +52,13 @@ builder.Services.AddSingleton(); // ── Feature handlers (explicit registration — AOT safe) ────────────── builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -74,6 +77,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredServic builder.Services.AddHostedService(); builder.Services.AddHostedService(); +// ── Clock and scheduling ────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + // ── Session scheduler ──────────────────────────────────────────────── builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index fd69d60..0afd0c0 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs new file mode 100644 index 0000000..a76a6ef --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs @@ -0,0 +1,214 @@ +using GmRelay.Bot.Features.Confirmation.SendConfirmation; +using GmRelay.Bot.Features.Reminders.SendJoinLink; +using GmRelay.Bot.Features.Reminders.SendOneHourReminder; +using GmRelay.Bot.Infrastructure.Scheduling; +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); + } + + 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); + } + } +} From 025c7c2f9a5a7c94a887a2911dc8f6f226b8a252 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 11 May 2026 13:49:30 +0300 Subject: [PATCH 2/2] fix(#20): reset confirmation_sent_at on reschedule and add guard - RescheduleVotingDeadlineService: clear confirmation_sent_at + confirmation_message_id when moving session back to Planned. - HandleRescheduleTimeInputHandler.RescheduleImmediately: same reset. - SendConfirmationHandler: add confirmation_sent_at IS NULL guard to prevent duplicate confirmation messages if DB update fails. Co-Authored-By: Claude Opus 4.7 --- .../Confirmation/SendConfirmation/SendConfirmationHandler.cs | 1 + .../RescheduleSession/HandleRescheduleTimeInputHandler.cs | 2 ++ .../RescheduleSession/RescheduleVotingDeadlineService.cs | 1 + 3 files changed, 4 insertions(+) diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs index f69011a..1a4f404 100644 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -112,6 +112,7 @@ public sealed class SendConfirmationHandler( confirmation_sent_at = now(), updated_at = now() WHERE id = @SessionId + AND confirmation_sent_at IS NULL """, new { diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index e66efb0..e255761 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -224,6 +224,8 @@ public sealed class HandleRescheduleTimeInputHandler( UPDATE sessions SET scheduled_at = @NewTime, status = @Status, + confirmation_message_id = NULL, + confirmation_sent_at = NULL, one_hour_reminder_processed_at = NULL, updated_at = now() WHERE id = @SessionId diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 046b126..3c856cd 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -169,6 +169,7 @@ public sealed class RescheduleVotingDeadlineService( SET scheduled_at = @NewTime, status = @Status, confirmation_message_id = NULL, + confirmation_sent_at = NULL, link_message_id = NULL, one_hour_reminder_processed_at = NULL, updated_at = now()