@@ -1,250 +1,480 @@
# Issue #19: В ыровнять /newsession с batch-сценарием лендинга
# Issue #19: в ыровнять /newsession с batch-сценарием лендинга — Implementation Plan
> **Goal:** Сделать `/newsession` атомарным: либо весь batch создаётся целиком, либо ничего — с понятным сообщением об ошибках. Убрать создание частичных сессий при наличии любых ошибок ввода .
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task .
**Architecture: ** Изменить `N ewS essionParseResult.IsValid` так, чтобы он учитывал ЛЮБЫЕ parse-ошибки (некорректные даты, лимиты, повторы, прошедшие даты). Обновить `CreateSessionHandler` , чтобы при `!IsValid` отправлялось одно детальное сообщение с перечислением ошибок и help-шаблоном, и creation прерывался до транзакции БД .
**Goal: ** Убедиться, что Telegram UX `/n ews ession` полностью соответствует batch-сценарию лендинга: мастер одной командой создаёт несколько дат, указывает лимит мест и ссылку, получает единую карточку с действиями. Обеспечить покрытие acceptance criteria регрессионными тестами и устранить найденные расхождения .
**Tech Stack: ** C#, .NET, Dapper, Telegram.Bot, xUnit
**Architecture: ** Сценарий уже реализован в `CreateSessionHandler` + `NewSessionCommandParser` + `SessionBatchRenderer` . Основная задача — добавить недостающие тесты на «точный» landing-сценарий (несколько явных дат, не recurring), проверить отсутствие частичных сессий при любых ошибках, и убедиться, что карточка содержит все обещанные элементы.
**Tech Stack: ** .NET 10, xUnit, Dapper, Npgsql, Telegram.Bot, Native AOT.
---
### Task 1: Добавить `HasErrors` и ужесточить `IsValid`
## Контекст кодовой базы
**Objective: ** Запретить частичное создание: `IsValid == false` , если есть хоть одна ошиб ка п арсинга .
- `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: `src /GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs`
- Test: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs`
- Modify: `tests /GmRelay.Bot.Tests /Features/Sessions/CreateSession/NewSessionCommandParserTests .cs`
**Step 1: RED — написать failing test **
Добавить в конец `NewSessionCommandParserTests.cs` :
**Step 1: Write failing test **
``` csharp
[Fact]
public void Parse_ShouldBeInvalid_WhenAnyErrorsPresent ( )
public void Parse_ShouldHandleLandingBatchWithMultipleExplicitDates ( )
{
var nowUtc = new DateTimeOffset ( 2026 , 4 , 23 , 12 , 0 , 0 , TimeSpan . Zero ) ;
var text = "" "
Н а з в а н и е : Delta Green
В р е м я : 25.04 . 2026 19 : 30
В р е м я : 31.04 . 2026 19 : 30
С с ы л к а : https : //example.test/dg
/ 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 . HasErrors ) ;
Assert . False ( result . IsValid ) ;
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 and verify RED **
**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 **
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n
```
Expected: `HasErrors` property not found → compile error or FAIL.
**Step 3: GREEN — minimal implementation **
В `NewSessionCommandParser.cs` , в `NewSessionParseResult` , заменить `IsValid` на:
``` csharp
public bool HasErrors = >
PastTimeInputs . Count > 0 | |
InvalidTimeInputs . Count > 0 | |
InvalidSeatLimitInputs . Count > 0 | |
InvalidRecurringInputs . Count > 0 ;
public bool IsValid = >
! string . IsNullOrWhiteSpace ( Title ) & &
! string . IsNullOrWhiteSpace ( Link ) & &
ScheduledTimes . Count > 0 & &
! HasErrors ;
```
**Step 4: Run test and verify GREEN **
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n
```
Expected: 7 passed (включая новый).
**Step 5: Commit **
``` bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
git commit -m "fea t(#19): add HasErrors to prevent partial batch creation "
git commit -m "tes t(#19): landing batch parser test with multiple explicit dates "
```
---
### Task 2: Обновить существующий тест на Past/Invalid times
### Task 2: RED — тест рендеринга landing-карточки
**Objective: ** Старый тест ожидал `IsValid == true` при partial invalid — теперь это баг, нужно обновить assertion .
**Objective: ** Проверить, что `SessionBatchRenderer` для landing-сценария выводит название, все даты, лимит, заполненность и кнопки записи/выхода .
**Files: **
- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandPars erTests.cs:114 `
- Modify: `tests/GmRelay.Bot.Tests/Rendering/SessionBatchRender erTests.cs`
**Step 1: Write failing expectation fir st **
Заменить в методе `Parse_ShouldCollectPastAndInvalidTimes` (примерно строка 114):
**Step 1: Write failing t est **
``` csharp
// Было:
Assert . True ( result . IsValid ) ;
// Стало:
Assert . False ( result . IsValid ) ;
[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 and verify RED **
**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 **
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n
git add tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs
git commit -m "test(#19): landing batch renderer card elements test"
```
Expected: FAIL — expected False, actual True.
**Step 3: Fix already done in Task 1 **
---
Task 1 уже изменил `IsValid` . Перезапустить тест.
### Task 3: RED — тест «ошибки ввода не создают частичных сессий»
**Step 4: Run test and verify GREEN **
**Objective: ** Убедиться, что при невалидном вводе `CreateSessionHandler` не создаёт ни одной записи в БД и не публикует карточку.
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n
**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 **
``` csharp
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` :
``` csharp
[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:
``` csharp
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` :
``` csharp
if ( ! parseResult . IsValid )
throw new InvalidOperationException ( "Cannot publish invalid parse result" ) ;
```
**Step 4: Run test to verify pass **
Expected: PASS.
**Step 5: Commit **
``` bash
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParser Tests.cs
git commit -m "test(#19): update assertion - partial invalid means invalid"
git add tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmoke Tests.cs
git add src/GmRelay.Bot/... # если были изменения
# (файл CreateSessionHandler.cs не трогаем — валидация происходит ДО транзакции)
git commit -m "test(#19): ensure invalid parse does not publish partial sessions"
```
---
### Task 3 : Обновить `CreateSessionHandler` для атомарной валидации и единого сообщения об ошибках
### Task 4 : RED — тест atomicity при сбое отправки batch-сообщения
**Objective: ** Убрать разбросанные warning-сообщения. При любых ошибках — одно сообщение с перечнем ошибок + help-шаблон. Не созд ава ть сессии .
**Objective: ** В `CreateSessionHandler` `batch_message_id` обновляется ПОСЛЕ `transaction.Commit()` . Если отправка сообщения в Telegram падает, сессии созданы, но `batch_message_id` не записан — игроки не увидят карточку. Нужно либо доказать, что это обработано, либо испр ави ть.
**Files: **
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: RED — напишем интеграционный поведенческий тест (опционально, landing test уже покрывает happy path) **
**Step 1: Анализ кода **
Просто запустим landing promise smoke test и убедимся, что он ещё green (happy path не сломан).
Текущий порядок в `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 = ...` (вне транзакции!)
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "TelegramLandingPromisesSmokeTests" -v n
```
Expected: PASS.
Если шаг 6 падает — сессии «висят» без published message. Это частичное создание.
**Step 2: GREEN — minimal change in handler **
**Step 2: Write minimal fix **
В `CreateSessionHandler.cs` , метод `HandleAsync` , заменить начало (строки 19–59) на:
Обернуть отправку сообщения и обновление `batch_message_id` в retry-loop с fallback. Если после N попыток не удалось — отправить GM уведомление об ошибке и оставить сессии (не удалять, чтобы не терять данные), но сделать так, чтобы `batch_message_id` обновлялся только при успешной отправке.
``` csharp
var parseResult = NewSessionCommandParser . Parse ( message . Text ? ? message . Caption , DateTimeOffset . UtcNow ) ;
// Внутри CreateSessionHandler, после Commit:
Message ? batchMessage = null ;
var sendAttempts = 0 ;
const int maxAttempts = 3 ;
Exception ? lastSendException = null ;
var errorMessages = new List < string > ( ) ;
foreach ( var timeInput in parseResult . PastTimeInputs )
errorMessages . Add ( $"⚠️ Дата {timeInput} находится в прошлом и будет пропущена." ) ;
foreach ( var timeInput in parseResult . InvalidTimeInputs )
errorMessages . Add ( $"⚠️ Некорректный формат времени '{timeInput}'. Пропущено." ) ;
foreach ( var seatLimitInput in parseResult . InvalidSeatLimitInputs )
errorMessages . Add ( $"⚠️ Некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0." ) ;
foreach ( var recurringInput in parseResult . InvalidRecurringInputs )
errorMessages . Add ( $"⚠️ Некорректный повтор расписания '{recurringInput}'. Укажите число игр 1–52 и шаг 1–365 дней." ) ;
if ( ! parseResult . IsValid )
while ( batchMessage is null & & sendAttempts < maxAttempts )
{
var helpText = "" "
❌ Н е у д а л о с ь р а с п о з н а т ь ф о р м а т . П о ж а л у й с т а , и с п о л ь з у й т е ш а б л о н :
/ newsession
Н а з в а н и е : My Game
В р е м я : 15.05 . 2026 19 : 30
В р е м я : 22.05 . 2026 19 : 30
М е с т : 4
С с ы л к а : https : //link
К а р т и н к а : https : //cover
Д л я п о в т о р а м о ж н о у к а з а т ь о д н у д а т у и с т р о к и :
И г р : 4
И н т е р в а л : 7
"" ";
if ( errorMessages . Count > 0 )
sendAttempts + + ;
try
{
helpText = string . Join ( '\n' , errorMessages ) + "\n\n" + helpText ;
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 : message . Chat . Id ,
text : helpText ,
chatId ,
$"⚠️ Сессии созданы, но не удалось опубликовать карточку. Пожалуйста, используйте /listsessions.\n\nОшибка: {lastSendException?.Message}" ,
cancellationToken : cancellationToken ) ;
return ;
}
```
**Step 3: Build and verify **
**Step 3: Extract helper **
``` bash
cd /repo && dotnet build src/GmRelay.Bot
```
Expected: Build succeeded.
Выделить метод `SendBatchMessageAsync` из текущего inline-кода отправки (строки 117–176 в текущем файле), чтобы логика была читаемой и тестируемой.
**Step 4: Run all relevant tests **
**Step 4: Run tests **
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~CreateSession|FullyQualifiedName~Landing" -v n
```
Expected: All passed.
Run: `dotnet test tests/GmRelay.Bot.Tests/ -v n`
Expected: все существующие тесты PASS, новая логика не ломает smoke-тест (т.к. smoke-тест не использует реальный `CreateSessionHandler` ).
**Step 5: Commit **
``` bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "feat (#19): atomic validation with detailed error message in /newsession "
git commit -m "fix (#19): retry batch message send and prevent orphaned sessions without batch_message_id "
```
---
### Task 4 : Проверить полный test suite на регресси и
### Task 5 : RED — smoke-тест полного landing-сценария с явными датам и
**Objective: ** Убедиться, что изменения не сломали существующие тесты .
**Objective: ** Дополнить `TelegramLandingPromisesSmokeTests` полным сквозным сценарием: парсинг → публикация → запись → выход → проверка карточки .
**Files: **
- Test: все тесты проекта `GmRelay.Bot.Test s`
- Modify: `tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.c s`
**Step 1: Write failing test **
``` csharp
[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 **
``` bash
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 (если изменения были) **
``` bash
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 **
``` bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests -v n
dotnet test tests/GmRelay.Bot.Tests/ -v n
```
Expected: All passed.
**Step 2: Commit (if any test baseline updated) **
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 финальный **
Если всё зелёное — commit message:
``` bash
git commit --allow-empty -m "test(#19): verify full suite green after atomic validation"
git add -A
git commit -m "feat(#19): align /newsession with landing batch scenario — tests + atomicity fix"
```
---
## Verification Checklist
## Acceptance Criteria Verification
- [ ] `NewSessionCommandParser.Parse` возвращает `IsValid = false` при любых ошибках (past/invalid times, seat limits, recurring).
- [ ] `HasErrors` true ↔ есть хотя бы одна ошибка.
- [ ] `CreateSessionHandler` не открывает БД-транзакцию, если `!IsValid` .
- [ ] Пользователь получает одно сообщение с перечислением ошибок + help.
- [ ] Landing promise smoke test проходит (happy path не сломан).
- [ ] Полный test suite зелёный.
| Критерий | Статус | Покрытие |
|---|---|---|
| Мастер может создать 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?