Files
GmRelayBot/.hermes/plans/gmrelay-issue-19.md
T
root 89b5196676
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / deploy (push) Successful in 12s
fix(#22): resolve Telegram namespace collision and add missing MoscowTime using
2026-05-06 09:23:52 +00:00

22 KiB
Raw Blame History

Issue #19: выровнять /newsession с batch-сценарием лендинга — Implementation Plan

For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.

Goal: Убедиться, что Telegram UX /newsession полностью соответствует batch-сценарию лендинга: мастер одной командой создаёт несколько дат, указывает лимит мест и ссылку, получает единую карточку с действиями. Обеспечить покрытие acceptance criteria регрессионными тестами и устранить найденные расхождения.

Architecture: Сценарий уже реализован в CreateSessionHandler + NewSessionCommandParser + SessionBatchRenderer. Основная задача — добавить недостающие тесты на «точный» landing-сценарий (несколько явных дат, не recurring), проверить отсутствие частичных сессий при любых ошибках, и убедиться, что карточка содержит все обещанные элементы.

Tech Stack: .NET 10, xUnit, Dapper, Npgsql, Telegram.Bot, Native AOT.


Контекст кодовой базы

  • src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs — создание batch, транзакция БД, отправка карточки.
  • src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs — парсинг команды: несколько Время:, Мест:, Ссылка:, Картинка:, recurring (Игр: + Интервал:).
  • src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs — рендеринг HTML-карточки с кнопками.
  • src/GmRelay.Shared/Rendering/BatchMessageEditor.cs — редактирование batch-сообщения (text/photo).
  • src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs — роутинг команд, текст /help.
  • tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs — тесты парсера.
  • tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs — smoke-тест всего landing-флоу через FakeTelegramMessenger.
  • tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs — тесты рендерера.

Task 1: RED — тест landing-парсинга с несколькими явными датами

Objective: Проверить, что парсер корректно обрабатывает точный формат из лендинга: 2+ явных даты, лимит мест, ссылка, без recurring.

Files:

  • Modify: tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs

Step 1: Write failing test

[Fact]
public void Parse_ShouldHandleLandingBatchWithMultipleExplicitDates()
{
    var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
    var text = """
        /newsession
        Название: Landing Batch Game
        Время: 15.05.2026 19:30
        Время: 22.05.2026 19:30
        Мест: 4
        Ссылка: https://example.test/landing
        """;

    var result = NewSessionCommandParser.Parse(text, nowUtc);

    Assert.True(result.IsValid);
    Assert.Equal("Landing Batch Game", result.Title);
    Assert.Equal("https://example.test/landing", result.Link);
    Assert.Equal(4, result.MaxPlayers);
    Assert.Equal(2, result.ScheduledTimes.Count);
    Assert.Equal(new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero), result.ScheduledTimes[0]);
    Assert.Equal(new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero), result.ScheduledTimes[1]);
    Assert.Empty(result.PastTimeInputs);
    Assert.Empty(result.InvalidTimeInputs);
    Assert.Empty(result.InvalidSeatLimitInputs);
    Assert.Empty(result.InvalidRecurringInputs);
}

Step 2: Run test to verify failure

Run: dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Parse_ShouldHandleLandingBatchWithMultipleExplicitDates" -v n

Expected: PASS (функциональность уже реализована, но теста не было). Если FAIL — исправить парсер перед продолжением.

Step 3: Commit

git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
git commit -m "test(#19): landing batch parser test with multiple explicit dates"

Task 2: RED — тест рендеринга landing-карточки

Objective: Проверить, что SessionBatchRenderer для landing-сценария выводит название, все даты, лимит, заполненность и кнопки записи/выхода.

Files:

  • Modify: tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs

Step 1: Write failing test

[Fact]
public void Render_ShouldProduceLandingBatchCardWithAllRequiredElements()
{
    var sessionId1 = Guid.NewGuid();
    var sessionId2 = Guid.NewGuid();
    var sessions = new[]
    {
        new SessionBatchDto(sessionId1, new DateTime(2026, 5, 15, 16, 30, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
        new SessionBatchDto(sessionId2, new DateTime(2026, 5, 22, 16, 30, 0, DateTimeKind.Utc), SessionStatus.Planned, 4)
    };
    var participants = new[]
    {
        new ParticipantBatchDto(sessionId1, "Alice", "alice", ParticipantRegistrationStatus.Active),
        new ParticipantBatchDto(sessionId1, "Bob", null, ParticipantRegistrationStatus.Active),
        new ParticipantBatchDto(sessionId2, "Charlie", "charlie", ParticipantRegistrationStatus.Waitlisted)
    };

    var result = SessionBatchRenderer.Render("Landing Batch Game", sessions, participants);
    var text = result.Text;
    var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();

    Assert.Contains("Landing Batch Game", text);
    Assert.Contains("15 мая 2026, 19:30", text);
    Assert.Contains("22 мая 2026, 19:30", text);
    Assert.Contains("Места: 2/4", text);
    Assert.Contains("Места: 0/4", text);
    Assert.Contains("@alice", text);
    Assert.Contains("Bob", text);
    Assert.Contains("Лист ожидания (1)", text);
    Assert.Contains("@charlie", text);

    Assert.Equal(4, buttons.Count);
    Assert.Contains($"join_session:{sessionId1}", buttons.Select(b => b.CallbackData));
    Assert.Contains($"leave_session:{sessionId1}", buttons.Select(b => b.CallbackData));
    Assert.Contains($"join_session:{sessionId2}", buttons.Select(b => b.CallbackData));
    Assert.Contains($"leave_session:{sessionId2}", buttons.Select(b => b.CallbackData));
}

Step 2: Run test to verify failure

Run: dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Render_ShouldProduceLandingBatchCardWithAllRequiredElements" -v n

Expected: PASS (рендерер уже реализован, тест отсутствовал).

Step 3: Commit

git add tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs
git commit -m "test(#19): landing batch renderer card elements test"

Task 3: RED — тест «ошибки ввода не создают частичных сессий»

Objective: Убедиться, что при невалидном вводе CreateSessionHandler не создаёт ни одной записи в БД и не публикует карточку.

Files:

  • Create: tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
  • Modify: src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs (если найдена уязвимость)

Step 1: Write failing test

using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql;
using Telegram.Bot;
using Microsoft.Extensions.Logging.Abstractions;

namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;

public sealed class CreateSessionHandlerTests
{
    // Примечание: полноценный интеграционный тест с реальной БД требует TestContainer.
    // Для Native AOT проекта используем подход с in-memory фейком через рефакторинг хендлера.
    // Ниже — тест-спецификация, которую реализуем через FakeDataSource или рефакторинг.
}

Поскольку CreateSessionHandler напрямую зависит от NpgsqlDataSource и ITelegramBotClient, для unit-тестирования нужно либо: а) использовать интеграционный тест с PostgreSQL (TestContainers), либо б) рефакторить хендлер, выделив ISessionRepository.

Рекомендуемый подход (YAGNI): добавить интеграционный тест в smoke-стиле через FakeTelegramMessenger, дополнив TelegramLandingSmokeScenario сценарием «invalid command does not publish anything».

Step 1 (реализация): Дописать тест в TelegramLandingPromisesSmokeTests.cs:

[Fact]
public void Smoke_InvalidNewSession_ShouldNotPublishAnySessions()
{
    var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
    var invalidText = """
        /newsession
        Название: Bad Game
        Время: 01.01.2020 19:30
        """; // нет ссылки

    var parseResult = NewSessionCommandParser.Parse(invalidText, nowUtc);

    Assert.False(parseResult.IsValid);
    Assert.Empty(parseResult.ScheduledTimes);
    // Убеждаемся, что smoke-сценарий не может быть опубликован
    Assert.Throws<InvalidOperationException>(() =>
        TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect));
}

В TelegramLandingSmokeScenario.Publish добавить guard:

if (!parseResult.IsValid)
    throw new InvalidOperationException("Cannot publish invalid parse result");

Step 2: Run test to verify failure

Run: dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Smoke_InvalidNewSession_ShouldNotPublishAnySessions" -v n

Expected: FAIL — guard ещё не добавлен.

Step 3: Write minimal implementation

Добавить guard в TelegramLandingSmokeScenario.Publish:

if (!parseResult.IsValid)
    throw new InvalidOperationException("Cannot publish invalid parse result");

Step 4: Run test to verify pass

Expected: PASS.

Step 5: Commit

git add tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs
git add src/GmRelay.Bot/... # если были изменения
# (файл CreateSessionHandler.cs не трогаем — валидация происходит ДО транзакции)
git commit -m "test(#19): ensure invalid parse does not publish partial sessions"

Task 4: RED — тест atomicity при сбое отправки batch-сообщения

Objective: В CreateSessionHandler batch_message_id обновляется ПОСЛЕ transaction.Commit(). Если отправка сообщения в Telegram падает, сессии созданы, но batch_message_id не записан — игроки не увидят карточку. Нужно либо доказать, что это обработано, либо исправить.

Files:

  • Modify: src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs

Step 1: Анализ кода

Текущий порядок в CreateSessionHandler:

  1. Валидация (до транзакции) ✓
  2. BEGIN TRANSACTION
  3. INSERT players, groups, sessions
  4. COMMIT
  5. SessionBatchRenderer.Render
  6. botClient.SendMessage/SendPhoto
  7. UPDATE sessions SET batch_message_id = ... (вне транзакции!)

Если шаг 6 падает — сессии «висят» без published message. Это частичное создание.

Step 2: Write minimal fix

Обернуть отправку сообщения и обновление batch_message_id в retry-loop с fallback. Если после N попыток не удалось — отправить GM уведомление об ошибке и оставить сессии (не удалять, чтобы не терять данные), но сделать так, чтобы batch_message_id обновлялся только при успешной отправке.

// Внутри CreateSessionHandler, после Commit:
Message? batchMessage = null;
var sendAttempts = 0;
const int maxAttempts = 3;
Exception? lastSendException = null;

while (batchMessage is null && sendAttempts < maxAttempts)
{
    sendAttempts++;
    try
    {
        batchMessage = await SendBatchMessageAsync(...); // extracted method
    }
    catch (Exception ex)
    {
        lastSendException = ex;
        logger.LogWarning(ex, "Attempt {Attempt} failed to send batch message for {BatchId}", sendAttempts, batchId);
        if (sendAttempts < maxAttempts)
            await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    }
}

if (batchMessage is not null)
{
    await connection.ExecuteAsync(
        "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
        new { MsgId = batchMessage.MessageId, BatchId = batchId });
}
else
{
    logger.LogError(lastSendException, "Failed to send batch message for {BatchId} after {MaxAttempts} attempts", batchId, maxAttempts);
    await botClient.SendMessage(
        chatId,
        $"⚠️ Сессии созданы, но не удалось опубликовать карточку. Пожалуйста, используйте /listsessions.\n\nОшибка: {lastSendException?.Message}",
        cancellationToken: cancellationToken);
}

Step 3: Extract helper

Выделить метод SendBatchMessageAsync из текущего inline-кода отправки (строки 117–176 в текущем файле), чтобы логика была читаемой и тестируемой.

Step 4: Run tests

Run: dotnet test tests/GmRelay.Bot.Tests/ -v n

Expected: все существующие тесты PASS, новая логика не ломает smoke-тест (т.к. smoke-тест не использует реальный CreateSessionHandler).

Step 5: Commit

git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "fix(#19): retry batch message send and prevent orphaned sessions without batch_message_id"

Task 5: RED — smoke-тест полного landing-сценария с явными датами

Objective: Дополнить TelegramLandingPromisesSmokeTests полным сквозным сценарием: парсинг → публикация → запись → выход → проверка карточки.

Files:

  • Modify: tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs

Step 1: Write failing test

[Fact]
public void Smoke_LandingExplicitDatesBatch_ShouldSupportFullLifecycle()
{
    var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
    var text = """
        /newsession
        Название: Landing Explicit Batch
        Время: 15.05.2026 19:30
        Время: 22.05.2026 19:30
        Мест: 3
        Ссылка: https://example.test/explicit
        """;

    var parseResult = NewSessionCommandParser.Parse(text, nowUtc);
    Assert.True(parseResult.IsValid);
    Assert.Equal(2, parseResult.ScheduledTimes.Count);
    Assert.Equal(3, parseResult.MaxPlayers);

    var scenario = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
    Assert.Contains("Landing Explicit Batch", scenario.LastMessage.Text);
    Assert.Contains("15 мая 2026, 19:30", scenario.LastMessage.Text);
    Assert.Contains("22 мая 2026, 19:30", scenario.LastMessage.Text);
    Assert.Contains("Места: 0/3", scenario.LastMessage.Text);

    var callbacks = CallbackData(scenario.LastMessage.Markup);
    Assert.Equal(4, callbacks.Count); // join+leave для каждой из 2 сессий

    var firstSessionId = scenario.Sessions[0].Id;
    var alice = scenario.Join(firstSessionId, 1001, "Alice", "alice");
    var bob = scenario.Join(firstSessionId, 1002, "Bob", "bob");
    var carol = scenario.Join(firstSessionId, 1003, "Carol", "carol");

    Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice));
    Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob));
    Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol));
    Assert.Contains("Места: 2/3", scenario.LastMessage.Text);
    Assert.Contains("@alice", scenario.LastMessage.Text);
    Assert.Contains("@bob", scenario.LastMessage.Text);
    Assert.Contains("@carol", scenario.LastMessage.Text);

    scenario.Leave(firstSessionId, alice);
    Assert.False(scenario.HasParticipant(firstSessionId, alice));
    Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol));
    Assert.DoesNotContain("@alice", scenario.LastMessage.Text);
    Assert.Contains("@carol", scenario.LastMessage.Text);
}

Step 2: Run test to verify failure

Run: dotnet test tests/GmRelay.Bot.Tests/ --filter "FullyQualifiedName~Smoke_LandingExplicitDatesBatch_ShouldSupportFullLifecycle" -v n

Expected: PASS (функциональность уже существует, тест добавляет регрессионное покрытие).

Step 3: Commit

git add tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs
git commit -m "test(#19): full lifecycle smoke test for explicit-dates landing batch"

Task 6: Проверка и обновление /help текста

Objective: Убедиться, что текст /help точно отражает landing-формат и упоминает batch-сценарий.

Files:

  • Modify: src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs

Step 1: Read current /help text

Текущий /help уже содержит:

/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover

Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7

Step 2: Verify alignment

  • Формат совпадает с лендингом ✓
  • Упоминается Мест:
  • Упоминается несколько Время:
  • Упоминается Ссылка:

Никаких изменений не требуется. Если тестировщик считает, что help недостаточно явно описывает batch-сценарий — добавить заголовок:

<b>Создать набор сессий (batch):</b>

Step 3: Commit (если изменения были)

git add src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs
git commit -m "docs(#19): clarify batch scenario in /help text"

Task 7: Финальный прогон и cleanup

Objective: Убедиться, что все тесты проходят, нет warnings, и план соответствует acceptance criteria.

Files:

  • Все изменённые файлы

Step 1: Run full test suite

dotnet test tests/GmRelay.Bot.Tests/ -v n

Expected: все тесты PASS.

Step 2: Verify checklist

  • Parse_ShouldHandleLandingBatchWithMultipleExplicitDates — PASS
  • Render_ShouldProduceLandingBatchCardWithAllRequiredElements — PASS
  • Smoke_InvalidNewSession_ShouldNotPublishAnySessions — PASS
  • Smoke_LandingExplicitDatesBatch_ShouldSupportFullLifecycle — PASS
  • Все существующие тесты — PASS
  • CreateSessionHandler обрабатывает сбой отправки batch-сообщения (retry + fallback)
  • /help текст соответствует landing-формату
  • Никаких новых warnings при сборке

Step 3: Commit финальный

git add -A
git commit -m "feat(#19): align /newsession with landing batch scenario — tests + atomicity fix"

Acceptance Criteria Verification

Критерий Статус Покрытие
Мастер может создать batch из нескольких дат Реализовано Task 1, Task 5
Карточка содержит название, даты, лимит, заполненность, действия Реализовано Task 2, Task 5
Ошибки ввода не создают частичных сессий Реализовано (валидация до транзакции) Task 3
Сбой публикации карточки не оставляет «висячие» сессии без batch_message_id 🔄 Фиксится Task 4

Execution Handoff

Plan complete and saved to .hermes/plans/gmrelay-issue-19.md. Ready to execute using subagent-driven-development — dispatch a fresh subagent per task with two-stage review (spec compliance then code quality). Shall I proceed?