diff --git a/.hermes/plans/gmrelay-issue-15.md b/.hermes/plans/gmrelay-issue-15.md deleted file mode 100644 index 05bc77c..0000000 --- a/.hermes/plans/gmrelay-issue-15.md +++ /dev/null @@ -1,343 +0,0 @@ -# Issue #15: Session Audit Log Implementation Plan - -> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. - -**Goal:** Add transparent audit history for every session change, visible to GM in Web Dashboard. - -**Architecture:** PostgreSQL audit table + Dapper queries. Automatic logging inside mutating SessionService methods. New Razor page for GM history view. - -**Tech Stack:** C# 12, Blazor SSR, Dapper, PostgreSQL, xUnit. - -**Branch:** `issue-15-session-audit-log` (includes mobile UI bugfix cherry-pick) - -**Version Bump:** `1.10.1` → `1.10.2` - ---- - -## Task 1: Database Migration (V013) - -**Objective:** Create `session_audit_log` table. - -**File:** Create `src/GmRelay.Bot/Migrations/V013__add_session_audit_log.sql` - -```sql -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - -CREATE TABLE session_audit_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - actor_telegram_id BIGINT NOT NULL, - actor_name VARCHAR(255) NOT NULL, - change_type VARCHAR(50) NOT NULL, - CHECK (change_type IN ('Title','Time','Link','MaxPlayers','Status','WaitlistPromote','PlayerRemoved','BatchRescheduled','Cancelled')), - old_value TEXT, - new_value TEXT, - changed_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX ix_session_audit_log_session_id ON session_audit_log(session_id); -CREATE INDEX ix_session_audit_log_changed_at ON session_audit_log(changed_at); -``` - -**Step 2:** Verify migration compiles: `psql $DATABASE_URL -f src/GmRelay.Bot/Migrations/V013__add_session_audit_log.sql` (optional, CI will test) - -**Step 3:** Commit: `git add src/GmRelay.Bot/Migrations/V013__add_session_audit_log.sql && git commit -m "chore(#15): add session_audit_log migration"` - ---- - -## Task 2: Domain Model & Interface Methods - -**Objective:** Add `SessionAuditLogEntry` record and two new methods to `ISessionStore`. - -**Files:** -- Modify: `src/GmRelay.Web/Services/ISessionStore.cs` -- Modify: `src/GmRelay.Web/Services/SessionService.cs` - -**Step 1: Add record to ISessionStore.cs** (after PlayerAttendanceStats) - -```csharp -public sealed record SessionAuditLogEntry( - Guid Id, - Guid SessionId, - long ActorTelegramId, - string ActorName, - string ChangeType, - string? OldValue, - string? NewValue, - DateTime ChangedAt -); -``` - -**Step 2: Add methods to ISessionStore interface** - -```csharp -Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue); -Task> GetSessionHistoryAsync(Guid sessionId); -``` - -**Step 3: Implement in SessionService.cs** - -```csharp -public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) -{ - using var connection = new NpgsqlConnection(connectionString); - await connection.ExecuteAsync( - "INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value) VALUES (@sessionId, @actorTelegramId, @actorName, @changeType, @oldValue, @newValue)", - new { sessionId, actorTelegramId, actorName, changeType, oldValue, newValue }); -} - -public async Task> GetSessionHistoryAsync(Guid sessionId) -{ - using var connection = new NpgsqlConnection(connectionString); - var entries = await connection.QueryAsync( - "SELECT id, session_id as SessionId, actor_telegram_id as ActorTelegramId, actor_name as ActorName, change_type as ChangeType, old_value as OldValue, new_value as NewValue, changed_at as ChangedAt FROM session_audit_log WHERE session_id = @sessionId ORDER BY changed_at DESC", - new { sessionId }); - return entries.ToList(); -} -``` - -**Step 4:** Commit: `git add -A && git commit -m "feat(#15): add SessionAuditLogEntry and audit store methods"` - ---- - -## Task 3: Instrument Mutating Methods - -**Objective:** Auto-log changes in key methods. Only log when value actually changes. - -**File:** Modify `src/GmRelay.Web/Services/SessionService.cs` - -**Step 1: Instrument UpdateSessionAsync** - -Before executing UPDATE, read current session. After UPDATE, compare and log each changed field: - -```csharp -// After var currentSession = await GetSessionAsync(sessionId); -// Before the UPDATE SQL: - -if (currentSession is not null) -{ - if (currentSession.Title != title) - await LogSessionChangeAsync(sessionId, /* actor will be passed from caller */ ...); -} -``` - -**Problem:** SessionService doesn't know the actor (GM) telegram ID. Solution: add `long actorTelegramId` parameter to mutating methods, or use a decorator pattern. - -**Better approach:** Instrument in `AuthorizedSessionService` which already has `gmId`. That is the authorized wrapper. - -**Revised approach:** Add `IAuditLogger` interface and inject it. Or simpler: change `ISessionStore` mutating methods to accept `long actorTelegramId` and `string actorName` as last parameters. - -**Simpler approach for this plan:** Since all mutating methods in `ISessionStore` are called through `AuthorizedSessionService`, instrument in `AuthorizedSessionService` methods AFTER the underlying `SessionService` call. BUT we need old values. - -**Final approach:** Change `ISessionStore` methods to include `actorTelegramId` parameter, and inside `SessionService` fetch old values and log. - -Let's add `long actorTelegramId` parameter to: -- `UpdateSessionAsync` -- `PromoteWaitlistedPlayerAsync` -- `RemovePlayerFromSessionAsync` -- `RescheduleBatchAsync` - -Wait, this is a bigger change. Let's be more pragmatic: add a new `IAuditService` that can be called from `AuthorizedSessionService` after mutations. `IAuditService` just wraps `ISessionStore.LogSessionChangeAsync`. - -Actually, simplest: add `actorTelegramId` and `actorName` parameters to `LogSessionChangeAsync` and call it from `AuthorizedSessionService` after each mutation. For old/new values, read the session before mutation in AuthorizedSessionService. - -**Plan:** -- `AuthorizedSessionService.UpdateSessionForGmAsync`: read session before update, call update, then log differences. -- Same pattern for other mutating methods. - -**Step 1: Read current session in AuthorizedSessionService.UpdateSessionForGmAsync** - -```csharp -public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) -{ - var groupId = await EnforceSessionOwnershipAsync(sessionId, gmId); - var before = await store.GetSessionAsync(sessionId); // add this - await store.UpdateSessionAsync(sessionId, groupId, title, scheduledAt, joinLink, maxPlayers); - if (before is not null) - { - if (before.Title != title) - await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", before.Title, title); - if (before.ScheduledAt != scheduledAt) - await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", before.ScheduledAt.ToString("O"), scheduledAt.ToString("O")); - if (before.JoinLink != joinLink) - await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", before.JoinLink, joinLink); - if (before.MaxPlayers != maxPlayers) - await store.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", before.MaxPlayers?.ToString(), maxPlayers?.ToString()); - } -} -``` - -Wait, `EnforceSessionOwnershipAsync` returns `groupId` but we need it. Let me check the current AuthorizedSessionService code... We saw earlier it does `await EnforceSessionOwnershipAsync(sessionId, gmId)` then calls `store.UpdateSessionAsync`. - -Actually, looking at the compact output from earlier, `AuthorizedSessionService` has `EnforceSessionOwnershipAsync` as a private helper. Let me verify. - -From the compact output of AuthorizedSessionService earlier: -``` -public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) -{ - var groupId = await EnforceSessionOwnershipAsync(sessionId, gmId); - await store.UpdateSessionAsync(sessionId, groupId, title, scheduledAt, joinLink, maxPlayers); -} -``` - -So it DOES return groupId. Perfect. - -But we need actor name. We can use the GM's display name from `GetGroupManagementForGmAsync` or just hardcode "ГМ" for now. Or we can query the player name. For simplicity, use "ГМ" + telegram ID, or query the player display name. - -Actually, let's query the GM name from the group or just use telegram ID as identifier. Since this is audit log, having human-readable name is better. We can pass `actorName` to the log method. - -Simpler: In `AuthorizedSessionService`, after `EnforceSessionOwnershipAsync`, we already know the group. We can get the GM name from group or just use a generic "ГМ" label. Actually, the telegram ID is sufficient for audit, and the display name can be resolved later in the UI. - -Let's just log `actor_telegram_id` and use "ГМ" as actor_name. Or better, query the player display name if needed. - -For this plan, let's keep it simple: log with `gmId` as actor_telegram_id and `"ГМ"` as actor_name. The UI can resolve the name. - -**Step 2: Instrument all mutating AuthorizedSessionService methods** - -Do this for: -- `UpdateSessionForGmAsync` (Title, Time, Link, MaxPlayers) -- `RescheduleBatchForGmAsync` (BatchRescheduled) -- `PromoteWaitlistedPlayerForGmAsync` (WaitlistPromote) -- `RemovePlayerFromSessionForGmAsync` (PlayerRemoved) -- `UpdateBatchDetailsForGmAsync` (Title, Link) -- `UpdateBatchNotificationModeForGmAsync` (maybe skip, internal) -- `DeleteSessionHandler` in Bot features (Cancelled) — this is in Bot layer, not Web - -For Cancelled status, we need to instrument `CancelSessionHandler` in Bot. But the Bot handlers don't use `ISessionStore` directly... they use `SessionService` or direct SQL. Let me check. - -Actually, for this plan, focus on Web layer mutations first. Bot layer can be a follow-up. - -**Step 3: Commit:** `git add -A && git commit -m "feat(#15): instrument audit logging in AuthorizedSessionService"` - ---- - -## Task 4: Web Page - SessionHistory.razor - -**Objective:** GM-visible timeline of session changes. - -**Files:** -- Create: `src/GmRelay.Web/Components/Pages/SessionHistory.razor` -- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor` (add "История" link) -- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor` (add "История" link) - -**Step 1: Create SessionHistory.razor** - -Route: `@page "/session/{SessionId:guid}/history"` - -Layout: table with columns: Время, Актор, Тип изменения, Было, Стало. - -Colors: use existing CSS variables. - -**Step 2: Add navigation links** - -In GroupDetails.razor sessions table: add 📜 История column/link. -In EditSession.razor: add button "📜 История изменений" linking to `/session/{SessionId}/history`. - -**Step 3: Commit:** `git add -A && git commit -m "feat(#15): add SessionHistory.razor page with audit timeline"` - ---- - -## Task 5: FakeSessionStore + TDD Tests - -**Objective:** Test audit logging. RED-GREEN-REFACTOR. - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs` - -**Step 1: RED — Write failing test** - -```csharp -[Fact] -public async Task UpdateSessionForGmAsync_LogsAudit_WhenTitleChanges() -{ - var gmId = 1001L; - var groupId = Guid.NewGuid(); - var sessionId = Guid.NewGuid(); - var store = new FakeSessionStore( - groups: [new(groupId, 42, "Alpha", gmId)], - sessions: [new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)]); - var service = new AuthorizedSessionService(store); - - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); - - Assert.Single(store.LogEntries); - Assert.Equal("Title", store.LogEntries[0].ChangeType); - Assert.Equal("Session A", store.LogEntries[0].OldValue); - Assert.Equal("Updated Title", store.LogEntries[0].NewValue); -} -``` - -Run: `dotnet test tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs --filter "FullyQualifiedName~LogsAudit" -v n` -Expected: FAIL (FakeSessionStore doesn't have LogSessionChangeAsync / LogEntries) - -**Step 2: GREEN — Add to FakeSessionStore** - -Add `List LogEntries` and implement `LogSessionChangeAsync` / `GetSessionHistoryAsync`. - -**Step 3: Refactor — Add more tests** - -- No audit when values unchanged -- Audit for time change -- Audit for link change -- Audit for max players change -- GetSessionHistoryAsync returns entries ordered by time desc - -**Step 4: Full suite** -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` -Expected: all pass - -**Step 5: Commit:** `git add -A && git commit -m "test(#15): add audit logging TDD tests"` - ---- - -## Task 6: Bump Version & Docs - -**Objective:** Update version and documentation. - -**Files:** -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `README.md` -- Modify: Wiki «Руководство ГМа» - -**Step 1: Bump version to 1.10.2** - -**Step 2: Update README** — add "📜 История изменений сессии" bullet in Web Dashboard section. - -**Step 3: Update wiki** — add subsection «Журнал действий» with description of how to access `/session/{id}/history`, what change types are tracked, and how to read the timeline. - -**Step 4: Commit:** `git add -A && git commit -m "docs(#15): bump version to 1.10.2 and add audit log docs"` - ---- - -## Task 7: Push & PR - -**Step 1:** `git push origin issue-15-session-audit-log` - -**Step 2:** Create PR via `mcp_gitea_pull_request_write` - -**Step 3:** Wait CI (`mcp_gitea_actions_run_read`) - -**Step 4:** Merge PR - -**Step 5:** Create tag `v1.10.2` and release - ---- - -## Verification Checklist - -- [ ] Migration V013 applied successfully -- [ ] `SessionAuditLogEntry` record defined in `ISessionStore.cs` -- [ ] `LogSessionChangeAsync` and `GetSessionHistoryAsync` implemented in `SessionService.cs` -- [ ] `AuthorizedSessionService` logs on value changes only -- [ ] `SessionHistory.razor` renders timeline for GM -- [ ] Links from GroupDetails and EditSession pages -- [ ] `FakeSessionStore` implements audit methods -- [ ] TDD tests: RED → GREEN → all pass -- [ ] Full `dotnet test` suite: all pass -- [ ] Version bumped to 1.10.2 -- [ ] README and wiki updated -- [ ] CI run success -- [ ] PR merged to main -- [ ] Tag `v1.10.2` created -- [ ] Release published diff --git a/.hermes/plans/gmrelay-issue-19.md b/.hermes/plans/gmrelay-issue-19.md deleted file mode 100644 index 0942152..0000000 --- a/.hermes/plans/gmrelay-issue-19.md +++ /dev/null @@ -1,480 +0,0 @@ -# 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(() => - 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-сценарий — добавить заголовок: -``` -Создать набор сессий (batch): -``` - -**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?