# 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**

```csharp
[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**

```bash
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**

```csharp
[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**

```bash
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**

```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/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` обновлялся только при успешной отправке.

```csharp
// Внутри 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**

```bash
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**

```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
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 финальный**

```bash
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?
