22 KiB
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:
- Валидация (до транзакции) ✓
BEGIN TRANSACTION- INSERT players, groups, sessions
COMMITSessionBatchRenderer.RenderbotClient.SendMessage/SendPhotoUPDATE 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— PASSRender_ShouldProduceLandingBatchCardWithAllRequiredElements— PASSSmoke_InvalidNewSession_ShouldNotPublishAnySessions— PASSSmoke_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?