feat(web): dashboard session deletion and E2E coverage for issue #150

- Add DeleteSessionAsync to ISessionStore/SessionService (unpublish portfolio card,
  remove bot-created empty forum topic, update batch message).
- Add DeleteSessionForCurrentUserAsync to AuthorizedSessionService with audit log.
- Add delete button + confirmation dialog to GroupDetails.razor.
- Extend dashboard Playwright tests with edit persistence and delete verification.
- Update AuthorizedSessionServiceTests with delete authorization coverage.
- Mark issue #150 as done in tests/e2e/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 13:05:48 +03:00
parent 892f39401c
commit b930991f06
14 changed files with 1140 additions and 28 deletions
@@ -10,6 +10,7 @@
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Сессии группы — GM-Relay</PageTitle>
@@ -393,6 +394,9 @@
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline">📜 История</a>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingSessionId == session.Id)" @onclick="() => DeleteSession(session.Id, session.Title)">
@(deletingSessionId == session.Id ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -491,6 +495,9 @@
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">📜 История</a>
<button type="button" class="btn-gm btn-gm-danger" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(deletingSessionId == session.Id)" @onclick="() => DeleteSession(session.Id, session.Title)">
@(deletingSessionId == session.Id ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -572,6 +579,7 @@
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
private Guid? loadingParticipantsSessionId;
private Guid? deletingSessionId;
protected override async Task OnInitializedAsync()
{
@@ -904,6 +912,40 @@
}
}
private async Task DeleteSession(Guid sessionId, string title)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Удалить сессию «{title}»?");
if (!confirmed)
{
return;
}
errorMessage = null;
successMessage = null;
deletingSessionId = sessionId;
try
{
await SessionService.DeleteSessionForCurrentUserAsync(sessionId);
expandedSessions.Remove(sessionId);
participantsCache.Remove(sessionId);
successMessage = "Сессия удалена.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
deletingSessionId = null;
}
}
private static string FormatParticipantUsername(WebParticipant p)
{
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
@@ -229,6 +229,22 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
}
public async Task DeleteSessionForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
var title = await sessionStore.DeleteSessionAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Deleted", title, null);
}
public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
@@ -150,6 +150,7 @@ public interface ISessionStore
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
@@ -129,6 +129,14 @@ internal sealed record WebBatchSessionRow(
string? CoverImageUrl = null);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebDeleteSessionInfo(
Guid Id,
string Title,
Guid BatchId,
long TelegramChatId,
int? BatchMessageId,
int? ThreadId,
bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(
Guid GroupId,
string Name,
@@ -1086,6 +1094,95 @@ public sealed class SessionService(
}
}
public async Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebDeleteSessionInfo>(
"""
SELECT s.id AS Id,
s.title AS Title,
s.batch_id AS BatchId,
g.external_group_id::BIGINT AS TelegramChatId,
s.batch_message_id AS BatchMessageId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE
""",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, "0");
}
await conn.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = @SessionId
AND pg.is_public = true
""",
new { SessionId = sessionId },
transaction);
await conn.ExecuteAsync(
"DELETE FROM sessions WHERE id = @Id AND group_id = @GroupId",
new { Id = sessionId, GroupId = groupId },
transaction);
var remainingInTopic = session.ThreadId.HasValue
? await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { GroupId = groupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync();
if (session.ThreadId.HasValue && session.TopicCreatedByBot && remainingInTopic == 0)
{
try
{
await bot.DeleteForumTopic(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId.Value);
logger.LogInformation(
"Deleted forum topic {ThreadId} for group {GroupId} as no sessions remained.",
session.ThreadId.Value,
groupId);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to delete forum topic {ThreadId} for group {GroupId}",
session.ThreadId.Value,
groupId);
}
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
return session.Title;
}
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -815,6 +815,7 @@ public sealed class AuthorizedPortfolioServiceTests
public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId) => throw new NotImplementedException();
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException();
public Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException();
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException();
@@ -765,6 +765,58 @@ public sealed class AuthorizedSessionServiceTests
Assert.False(store.CreateBatchFromTemplateCalled);
}
[Fact]
public async Task DeleteSessionForCurrentUserAsync_Deletes_WhenSessionBelongsToManager()
{
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 accessor = CreateAccessor(gmId.ToString(System.Globalization.CultureInfo.InvariantCulture));
var service = new AuthorizedSessionService(store, accessor);
await service.DeleteSessionForCurrentUserAsync(sessionId);
Assert.True(store.DeleteCalled);
Assert.Equal(sessionId, store.LastDeletedSessionId);
Assert.Equal(groupId, store.LastDeletedGroupId);
Assert.Null(await store.GetSessionAsync(sessionId));
}
[Fact]
public async Task DeleteSessionForCurrentUserAsync_Throws_WhenSessionDoesNotBelongToManager()
{
var gmId = 1001L;
var otherGmId = 2002L;
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId),
new(otherGroupId, 43, "Beta", otherGmId)
],
sessions:
[
new(sessionId, otherGroupId, "Session B", DateTime.UtcNow, "Planned", "https://example.test/b", Guid.NewGuid(), 10, 43, 4, 1, 0)
]);
var accessor = CreateAccessor(gmId.ToString(System.Globalization.CultureInfo.InvariantCulture));
var service = new AuthorizedSessionService(store, accessor);
await Assert.ThrowsAsync<SessionAccessDeniedException>(
() => service.DeleteSessionForCurrentUserAsync(sessionId));
Assert.False(store.DeleteCalled);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null,
@@ -796,6 +848,9 @@ public sealed class AuthorizedSessionServiceTests
public DateTime? LastUpdatedScheduledAt { get; private set; }
public string? LastUpdatedJoinLink { get; private set; }
public int? LastUpdatedMaxPlayers { get; private set; }
public bool DeleteCalled { get; private set; }
public Guid? LastDeletedSessionId { get; private set; }
public Guid? LastDeletedGroupId { get; private set; }
public Guid? LastPromotedSessionId { get; private set; }
public Guid? LastPromotedGroupId { get; private set; }
public Guid? LastUpdatedBatchId { get; private set; }
@@ -1036,6 +1091,15 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId)
{
DeleteCalled = true;
LastDeletedSessionId = sessionId;
LastDeletedGroupId = groupId;
sessionsById.Remove(sessionId);
return Task.FromResult<string?>("Deleted session");
}
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
PromoteCalled = true;
+23 -12
View File
@@ -12,10 +12,10 @@ Tracked as a Gitea milestone: [E2E Automation](https://git.codeanddice.ru/toutsu
| #144 | initData / Login Widget helper for mock Telegram auth | ✅ Done |
| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | ✅ Done |
| #146 | Telegram user client (MTProto) | ✅ Done |
| #147 | Automate group creation and bot invitation | 🚧 In progress |
| #148 | Scenario: /newsession from creation to publication | ⏳ Planned |
| #149 | Join/leave, waitlist, reschedule and notification scenarios | ⏳ Planned |
| #150 | Dashboard display and editing verification | ⏳ Planned |
| #147 | Automate group creation and bot invitation | ✅ Done |
| #148 | Scenario: /newsession from creation to publication | ✅ Done |
| #149 | Join/leave, waitlist, reschedule and notification scenarios | ✅ Done |
| #150 | Dashboard display and editing verification | ✅ Done |
| #151 | Console runner and cleanup | ⏳ Planned |
## Structure
@@ -36,11 +36,14 @@ tests/e2e/
├── GmRelay.E2E.Runner.csproj # C# console runner using WTelegramClient (MTProto)
├── Program.cs # Entry point for quick manual checks
├── TelegramUserClient.cs # Reusable MTProto user client wrapper
├── GroupSetupScenario.cs # Create group + invite bot + verify /start
├── RunnerConfig.cs # Configuration model
├── .env.example # Required environment variables
├── .gitignore # Ignore .env and session files
── packages.lock.json # Restored lock file for the runner project
├── GroupSetupScenario.cs # Create group + invite bot + verify /start
├── NewSessionScenario.cs # Walk the /newsession wizard end-to-end
├── JoinLeaveWaitlistRescheduleScenario.cs # Join, waitlist, promotion, leave, reschedule, notifications
├── DatabaseAssertions.cs # Query PostgreSQL and seed fake participants
── RunnerConfig.cs # Configuration model
├── .env.example # Required environment variables
├── .gitignore # Ignore .env and session files
└── packages.lock.json # Restored lock file for the runner project
```
## Install dependencies
@@ -109,7 +112,9 @@ The runner logs in to a real Telegram user account, creates a supergroup, invite
- `test_dashboard_authenticates_and_shows_groups`
Builds a valid Mini App initData payload, posts it to `/auth/telegram-webapp`, and verifies that the Blazor home page renders the authenticated greeting.
- `test_dashboard_session_edit_flow`
Seeds a player, group, and session directly in PostgreSQL, opens the group details page, clicks through to the session editor, changes the title, and asserts the updated title appears on the page.
Seeds a player, group, and session directly in PostgreSQL, opens the group details page, clicks through to the session editor, changes the title, join link, max players and publication mode, asserts the updated values appear on the page, and verifies the persisted database state.
- `test_dashboard_session_delete_flow`
Seeds a session, opens the group details page, confirms the deletion dialog, asserts the session disappears from the dashboard, and verifies the session was removed from PostgreSQL.
## What the MTProto runner currently covers
@@ -117,13 +122,19 @@ The runner logs in to a real Telegram user account, creates a supergroup, invite
- Create a supergroup (`Channels_CreateChannel` with `megagroup: true`).
- Resolve a bot by username and invite it to the group.
- Send `/start` to the bot inside the group and wait for any reply.
- Walk `/newsession` from start to published schedule message.
- Join/leave a session via inline buttons and assert the database state.
- Join into a waitlist when the active roster is full (using a seeded fake participant).
- Manually promote a waitlisted player via `/listsessions` and verify promotion.
- Leave an active session and verify automatic promotion of the next waitlisted player.
- Initiate a reschedule, propose 2-3 time options, vote, and wait for the background deadline service to apply the new time.
- Exercise T-24h RSVP confirmation and T-5m join-link reminders by time-travelling `sessions.scheduled_at` from the runner (database-level time-mock).
- Delete the test supergroup after the scenario (cleanup).
- Send messages/commands and read recent messages.
- Wait for a bot reply.
## Notes
- Authentication is mocked using `helpers/telegram_init_data.py`, which mirrors `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`.
- The Web instance validates HMAC-SHA256 with the same bot token, so the test payload is indistinguishable from a real Telegram Mini App payload.
- The runner project is intentionally **not** included in `GM-Relay.slnx` so it does not participate in CI builds or Native AOT trimming.
- The Telegram lifecycle scenario uses **one real test account** plus fake participants seeded directly into PostgreSQL, and a **database-level time-mock** (`UPDATE sessions SET scheduled_at = ...`) so 24-hour and 5-minute reminders fire in minutes instead of hours. This is the locally-runnable approach chosen for issue #149.
- For headful debugging, change `headless=True` to `headless=False` in the dashboard test file.
@@ -12,6 +12,7 @@ Environment:
GMRELAY_E2E_BASE_URL - Web dashboard URL (default: http://localhost:8080)
GMRELAY_E2E_BOT_TOKEN - Bot token matching the target Web instance
GMRELAY_E2E_TELEGRAM_ID - Telegram ID to use for the mocked user
GMRELAY_E2E_DATABASE_URL - PostgreSQL DSN for DB assertions (optional)
"""
from __future__ import annotations
@@ -19,6 +20,7 @@ from __future__ import annotations
import os
import time
import urllib.parse
import uuid
from typing import Optional
from playwright.sync_api import Page, expect, sync_playwright
@@ -48,12 +50,17 @@ def _telegram_id() -> int:
return int(os.environ.get("GMRELAY_E2E_TELEGRAM_ID", "9000000001"))
def _database_url() -> Optional[str]:
return os.environ.get("GMRELAY_E2E_DATABASE_URL")
def _seed_group_in_database(
telegram_id: int,
group_id: str,
group_name: str,
session_id: str,
session_title: str,
batch_id: Optional[str] = None,
) -> None:
"""
Insert minimal test data via the shared Npgsql connection so the dashboard
@@ -62,7 +69,7 @@ def _seed_group_in_database(
"""
import psycopg2
dsn = os.environ.get("GMRELAY_E2E_DATABASE_URL")
dsn = _database_url()
if not dsn:
return # rely on a pre-seeded dev database
@@ -70,6 +77,7 @@ def _seed_group_in_database(
external_user_id = str(telegram_id)
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
scheduled_at = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + 86400))
batch_id = batch_id or str(uuid.uuid4())
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
@@ -77,7 +85,9 @@ def _seed_group_in_database(
"""
INSERT INTO gmrelay.players (platform, external_user_id, display_name, created_at)
VALUES (%s, %s, %s, %s)
ON CONFLICT (platform, external_user_id) DO UPDATE SET display_name = EXCLUDED.display_name
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name
RETURNING id
""",
(platform, external_user_id, "E2E Test GM", now),
@@ -93,27 +103,37 @@ def _seed_group_in_database(
""",
(group_id, platform, "-1001234567890", -1001234567890, group_name, now),
)
cur.execute(
"""
INSERT INTO gmrelay.group_managers (group_id, platform, external_user_id, role, created_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (group_id, platform, external_user_id) DO UPDATE SET role = EXCLUDED.role
INSERT INTO gmrelay.group_managers (group_id, player_id, role, created_at)
VALUES (%s, %s, %s, %s)
ON CONFLICT (group_id, player_id) DO UPDATE SET role = EXCLUDED.role
""",
(group_id, platform, external_user_id, "Owner", now),
(group_id, player_id, "Owner", now),
)
cur.execute(
"""
INSERT INTO gmrelay.sessions (
id, group_id, title, scheduled_at, join_link, status, publication_mode,
notification_mode, max_players, created_at
id, group_id, batch_id, title, scheduled_at, join_link, status, publication_mode,
notification_mode, max_players, format, location_address, system, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
scheduled_at = EXCLUDED.scheduled_at,
join_link = EXCLUDED.join_link,
max_players = EXCLUDED.max_players,
publication_mode = EXCLUDED.publication_mode,
format = EXCLUDED.format,
location_address = EXCLUDED.location_address,
system = EXCLUDED.system
""",
(
session_id,
group_id,
batch_id,
session_title,
scheduled_at,
"https://example.com/join",
@@ -121,12 +141,16 @@ def _seed_group_in_database(
"None",
"GroupAndDirect",
5,
"Online",
"Discord",
"D&D 5e",
now,
),
)
cur.execute(
"""
INSERT INTO gmrelay.session_players (session_id, player_id, registration_status, rsvp_status, is_gm, created_at)
INSERT INTO gmrelay.session_participants (session_id, player_id, registration_status, rsvp_status, is_gm, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (session_id, player_id) DO NOTHING
""",
@@ -136,19 +160,68 @@ def _seed_group_in_database(
conn.commit()
def _delete_test_data(group_id: str, session_id: str) -> None:
def _get_session_from_db(session_id: str):
import psycopg2
dsn = os.environ.get("GMRELAY_E2E_DATABASE_URL")
dsn = _database_url()
if not dsn:
return None
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT title, join_link, max_players, publication_mode, format, location_address, system, status
FROM gmrelay.sessions
WHERE id = %s
""",
(session_id,),
)
row = cur.fetchone()
if row is None:
return None
return {
"title": row[0],
"join_link": row[1],
"max_players": row[2],
"publication_mode": row[3],
"format": row[4],
"location_address": row[5],
"system": row[6],
"status": row[7],
}
def _assert_session_deleted(session_id: str) -> None:
import psycopg2
dsn = _database_url()
if not dsn:
return
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM gmrelay.session_players WHERE session_id = %s", (session_id,))
cur.execute("SELECT COUNT(*) FROM gmrelay.sessions WHERE id = %s", (session_id,))
assert cur.fetchone()[0] == 0, f"Session {session_id} was not deleted"
def _delete_test_data(group_id: str, session_id: str) -> None:
import psycopg2
dsn = _database_url()
if not dsn:
return
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM gmrelay.session_participants WHERE session_id = %s", (session_id,))
cur.execute("DELETE FROM gmrelay.sessions WHERE id = %s", (session_id,))
cur.execute("DELETE FROM gmrelay.group_managers WHERE group_id = %s", (group_id,))
cur.execute("DELETE FROM gmrelay.game_groups WHERE id = %s", (group_id,))
cur.execute(
"DELETE FROM gmrelay.players WHERE external_user_id = %s AND platform = 'Telegram'",
(str(_telegram_id()),),
)
conn.commit()
@@ -204,8 +277,10 @@ def test_dashboard_session_edit_flow() -> None:
group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"
session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa1"
batch_id = "cccccccc-cccc-cccc-cccc-aaaaaaaaaaa1"
original_title = "E2E Original Title"
updated_title = "E2E Updated Title"
updated_join_link = "https://example.com/updated-join"
try:
_seed_group_in_database(
@@ -214,6 +289,7 @@ def test_dashboard_session_edit_flow() -> None:
group_name="E2E Test Group",
session_id=session_id,
session_title=original_title,
batch_id=batch_id,
)
with sync_playwright() as p:
@@ -230,15 +306,77 @@ def test_dashboard_session_edit_flow() -> None:
page.locator(f"text={original_title}").first.click()
page.wait_for_selector("text=Редактирование сессии", timeout=15000)
title_input = page.locator("input[placeholder*='Dragon\'s Hoard']").first
title_input = page.get_by_label("Название игры")
title_input.fill(updated_title)
join_input = page.get_by_label("Ссылка для подключения")
join_input.fill(updated_join_link)
max_players_input = page.get_by_label("Лимит мест")
max_players_input.fill("3")
page.get_by_label("Режим публикации").select_option("Catalog")
save_button = page.locator("button:has-text('Сохранить изменения')").first
save_button.click()
page.wait_for_selector(f"text={updated_title}", timeout=15000)
expect(page.locator(f"text={updated_title}").first).to_be_visible()
db_session = _get_session_from_db(session_id)
if db_session is not None:
assert db_session["title"] == updated_title
assert db_session["join_link"] == updated_join_link
assert db_session["max_players"] == 3
assert db_session["publication_mode"] == "Catalog"
browser.close()
finally:
_delete_test_data(group_id, session_id)
def test_dashboard_session_delete_flow() -> None:
base_url = _base_url()
bot_token = _bot_token()
telegram_id = _telegram_id()
group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"
session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa2"
batch_id = "cccccccc-cccc-cccc-cccc-aaaaaaaaaaa2"
session_title = "E2E Delete Target"
try:
_seed_group_in_database(
telegram_id=telegram_id,
group_id=group_id,
group_name="E2E Delete Group",
session_id=session_id,
session_title=session_title,
batch_id=batch_id,
)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
_authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page)
page.goto(f"{base_url}/group/{group_id}")
page.wait_for_selector(f"text={session_title}", timeout=15000)
page.on("dialog", lambda dialog: dialog.accept())
delete_button = page.locator("button:has-text('Удалить')").first
expect(delete_button).to_be_visible()
delete_button.click()
page.wait_for_selector("text=Сессия удалена.", timeout=15000)
expect(page.locator(f"text={session_title}")).to_have_count(0)
_assert_session_deleted(session_id)
browser.close()
finally:
_delete_test_data(group_id, session_id)
@@ -249,3 +387,5 @@ if __name__ == "__main__":
print("PASS test_dashboard_authenticates_and_shows_groups")
test_dashboard_session_edit_flow()
print("PASS test_dashboard_session_edit_flow")
test_dashboard_session_delete_flow()
print("PASS test_dashboard_session_delete_flow")
+361
View File
@@ -0,0 +1,361 @@
using Npgsql;
namespace GmRelay.E2E.Runner;
public sealed record SessionDto(
Guid Id,
string Title,
string Status,
DateTimeOffset ScheduledAt,
int? MaxPlayers,
int? BatchMessageId,
int? ConfirmationMessageId,
int? LinkMessageId,
string? JoinLink,
Guid GroupId);
public sealed record ParticipantDto(
Guid ParticipantId,
string DisplayName,
string ExternalUserId,
string RegistrationStatus,
string RsvpStatus,
bool IsGm);
public sealed record RescheduleProposalDto(
Guid Id,
string Status,
DateTimeOffset? VotingDeadlineAt,
int? VoteMessageId,
Guid? SelectedOptionId);
public sealed record RescheduleOptionDto(
Guid Id,
int DisplayOrder,
DateTimeOffset ProposedAt);
/// <summary>
/// PostgreSQL helper for the E2E runner. Allows assertions against the actual
/// application state and small, controlled seeds (e.g. a fake second player)
/// that would otherwise require multiple real Telegram test accounts.
/// </summary>
public sealed class DatabaseAssertions(RunnerConfig config)
{
private NpgsqlConnection CreateConnection() => new(config.DatabaseUrl);
public async Task<SessionDto?> GetSessionByIdAsync(Guid sessionId)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
SELECT s.id AS Id,
s.title AS Title,
s.status AS Status,
s.scheduled_at AS ScheduledAt,
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
s.confirmation_message_id AS ConfirmationMessageId,
s.link_message_id AS LinkMessageId,
s.join_link AS JoinLink,
s.group_id AS GroupId
FROM sessions s
WHERE s.id = @SessionId
""",
connection);
command.Parameters.AddWithValue("@SessionId", sessionId);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return MapSession(reader);
}
public async Task<SessionDto?> GetSessionByTitleAsync(long telegramChatId, string title)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
SELECT s.id AS Id,
s.title AS Title,
s.status AS Status,
s.scheduled_at AS ScheduledAt,
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
s.confirmation_message_id AS ConfirmationMessageId,
s.link_message_id AS LinkMessageId,
s.join_link AS JoinLink,
s.group_id AS GroupId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE g.external_group_id::BIGINT = @ExternalGroupId
AND s.title = @Title
ORDER BY s.created_at DESC
LIMIT 1
""",
connection);
command.Parameters.AddWithValue("@ExternalGroupId", telegramChatId);
command.Parameters.AddWithValue("@Title", title);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return MapSession(reader);
}
public async Task<IReadOnlyList<ParticipantDto>> GetParticipantsAsync(Guid sessionId)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
SELECT sp.id AS ParticipantId,
p.display_name AS DisplayName,
p.external_user_id AS ExternalUserId,
sp.registration_status AS RegistrationStatus,
sp.rsvp_status AS RsvpStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
ORDER BY sp.created_at, sp.id
""",
connection);
command.Parameters.AddWithValue("@SessionId", sessionId);
var participants = new List<ParticipantDto>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
participants.Add(new ParticipantDto(
reader.GetGuid(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetString(4),
reader.GetBoolean(5)));
}
return participants;
}
public async Task<RescheduleProposalDto?> GetActiveRescheduleProposalAsync(Guid sessionId)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
SELECT id,
status,
voting_deadline_at,
vote_message_id,
selected_option_id
FROM reschedule_proposals
WHERE session_id = @SessionId
AND status IN ('AwaitingTime', 'Voting')
ORDER BY created_at DESC
LIMIT 1
""",
connection);
command.Parameters.AddWithValue("@SessionId", sessionId);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return new RescheduleProposalDto(
reader.GetGuid(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : new DateTimeOffset(reader.GetDateTime(2), TimeSpan.Zero),
reader.IsDBNull(3) ? null : reader.GetInt32(3),
reader.IsDBNull(4) ? null : reader.GetGuid(4));
}
public async Task<IReadOnlyList<RescheduleOptionDto>> GetRescheduleOptionsAsync(Guid proposalId)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
SELECT id, display_order, proposed_at
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
connection);
command.Parameters.AddWithValue("@ProposalId", proposalId);
var options = new List<RescheduleOptionDto>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
options.Add(new RescheduleOptionDto(
reader.GetGuid(0),
reader.GetInt32(1),
reader.GetDateTime(2)));
}
return options;
}
/// <summary>
/// Moves a session forward or backward in time from the database perspective.
/// Used as a time-mock so the scheduler background services fire quickly
/// during local E2E runs instead of waiting real 24 hours.
/// </summary>
public async Task TimeTravelAsync(Guid sessionId, DateTimeOffset newScheduledAt)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
UPDATE sessions
SET scheduled_at = @ScheduledAt,
updated_at = now()
WHERE id = @SessionId
""",
connection);
command.Parameters.AddWithValue("@ScheduledAt", newScheduledAt);
command.Parameters.AddWithValue("@SessionId", sessionId);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Resets notification flags so a session can trigger the T-24h confirmation
/// and T-5m join-link reminders again after time-travel.
/// </summary>
public async Task ResetNotificationFlagsAsync(Guid sessionId)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"""
UPDATE sessions
SET confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
status = CASE WHEN status = 'ConfirmationSent' THEN 'Planned' ELSE status END,
updated_at = now()
WHERE id = @SessionId
""",
connection);
command.Parameters.AddWithValue("@SessionId", sessionId);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Inserts a fake Telegram player and adds them to a session. This lets a
/// single real test account exercise waitlist/promotion flows that would
/// normally require a second Telegram user.
/// </summary>
public async Task<Guid> SeedFakeParticipantAsync(
Guid sessionId,
string displayName,
long externalUserId,
string registrationStatus,
string rsvpStatus = "Pending",
bool isGm = false)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
Guid playerId;
await using var playerCommand = new NpgsqlCommand(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@DisplayName, 'Telegram', @ExternalUserId, NULL)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username
RETURNING id
""",
connection,
transaction);
playerCommand.Parameters.AddWithValue("@DisplayName", displayName);
playerCommand.Parameters.AddWithValue("@ExternalUserId", externalUserId.ToString(System.Globalization.CultureInfo.InvariantCulture));
var result = await playerCommand.ExecuteScalarAsync();
playerId = (Guid)(result ?? throw new InvalidOperationException("Failed to seed fake player."));
await using var participantCommand = new NpgsqlCommand(
"""
INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
VALUES (@SessionId, @PlayerId, @IsGm, @RsvpStatus, @RegistrationStatus)
ON CONFLICT (session_id, player_id) DO UPDATE
SET rsvp_status = EXCLUDED.rsvp_status,
registration_status = EXCLUDED.registration_status,
is_gm = EXCLUDED.is_gm
RETURNING id
""",
connection,
transaction);
participantCommand.Parameters.AddWithValue("@SessionId", sessionId);
participantCommand.Parameters.AddWithValue("@PlayerId", playerId);
participantCommand.Parameters.AddWithValue("@IsGm", isGm);
participantCommand.Parameters.AddWithValue("@RsvpStatus", rsvpStatus);
participantCommand.Parameters.AddWithValue("@RegistrationStatus", registrationStatus);
await participantCommand.ExecuteNonQueryAsync();
await transaction.CommitAsync();
return playerId;
}
public async Task UpdateSessionMaxPlayersAsync(Guid sessionId, int? maxPlayers)
{
await using var connection = CreateConnection();
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"UPDATE sessions SET max_players = @MaxPlayers, updated_at = now() WHERE id = @SessionId",
connection);
if (maxPlayers.HasValue)
command.Parameters.AddWithValue("@MaxPlayers", maxPlayers.Value);
else
command.Parameters.AddWithValue("@MaxPlayers", DBNull.Value);
command.Parameters.AddWithValue("@SessionId", sessionId);
await command.ExecuteNonQueryAsync();
}
private static SessionDto MapSession(NpgsqlDataReader reader) =>
new(
reader.GetGuid(0),
reader.GetString(1),
reader.GetString(2),
reader.GetDateTime(3),
reader.IsDBNull(4) ? null : reader.GetInt32(4),
reader.IsDBNull(5) ? null : reader.GetInt32(5),
reader.IsDBNull(6) ? null : reader.GetInt32(6),
reader.IsDBNull(7) ? null : reader.GetInt32(7),
reader.IsDBNull(8) ? null : reader.GetString(8),
reader.GetGuid(9));
}
@@ -11,6 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="10.0.2" />
<PackageReference Include="WTelegramClient" Version="4.3.5" />
<PackageReference Include="dotenv.net" Version="3.2.1" />
</ItemGroup>
@@ -0,0 +1,370 @@
using System.Globalization;
using TL;
namespace GmRelay.E2E.Runner;
/// <summary>
/// E2E scenario covering the product lifecycle of a session after it has been
/// published: join, waitlist, promotion, leave + auto-promotion, reschedule
/// voting, T-24h RSVP confirmation and T-5m join-link notifications.
///
/// A single real Telegram test account is enough because waitlist scenarios
/// that need a second player are satisfied by seeding a fake participant
/// directly into PostgreSQL. Notification deadlines are accelerated by updating
/// <c>sessions.scheduled_at</c> from the runner (a database-level time-mock).
/// </summary>
public sealed class JoinLeaveWaitlistRescheduleScenario
{
private readonly TelegramUserClient _client;
private readonly RunnerConfig _config;
private readonly DatabaseAssertions _db;
public JoinLeaveWaitlistRescheduleScenario(TelegramUserClient client, RunnerConfig config)
{
_client = client;
_config = config;
_db = new DatabaseAssertions(config);
}
public async Task RunAsync(ChatGroup group, CancellationToken cancellationToken = default)
{
var baseSession = await CreateSessionAsync(group, maxPlayers: 2, cancellationToken);
Console.WriteLine($"[scenario] base session {baseSession.Id} created");
await JoinActiveAsync(group, baseSession, cancellationToken);
await RescheduleAndNotifyAsync(group, baseSession, cancellationToken);
await ManualPromoteAsync(group, baseSession, cancellationToken);
await AutoPromoteLeaveAsync(group, baseSession, cancellationToken);
var waitlistSession = await CreateSessionAsync(group, maxPlayers: 1, cancellationToken);
Console.WriteLine($"[scenario] waitlist session {waitlistSession.Id} created");
await JoinWaitlistAsync(group, waitlistSession, cancellationToken);
Console.WriteLine("[scenario] join/leave/waitlist/reschedule/notification flow completed");
}
private async Task<SessionDto> CreateSessionAsync(
ChatGroup group,
int? maxPlayers,
CancellationToken ct)
{
var title = $"E2E JLW {DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString()[..4]}";
var scheduledAtMoscow = DateTime.UtcNow
.AddDays(7)
.AddHours(3)
.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
var inputs = new NewSessionInputs(
Title: title,
ScheduledAtMoscow: scheduledAtMoscow,
MaxPlayers: maxPlayers ?? 5,
JoinLink: "https://example.com/join-e2e");
var wizard = new NewSessionScenario(_client, _config);
await wizard.RunAsync(group, inputs, ct);
var session = await _db.GetSessionByTitleAsync(group.Id, title);
return session ?? throw new InvalidOperationException($"Session '{title}' was not found in the database.");
}
private async Task JoinActiveAsync(ChatGroup group, SessionDto session, CancellationToken ct)
{
Console.WriteLine("[scenario] joining base session as active player");
await _client.ClickInlineButtonAsync(
group,
$"join_session:{session.Id}",
session.BatchMessageId,
ct);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
var me = await GetCurrentParticipantAsync(session.Id, ct);
if (me?.RegistrationStatus != "Active")
throw new InvalidOperationException("Expected current user to be an active participant after join.");
Console.WriteLine("[scenario] joined as active player");
}
private async Task RescheduleAndNotifyAsync(ChatGroup group, SessionDto session, CancellationToken ct)
{
Console.WriteLine("[scenario] initiating reschedule");
await _client.ClickInlineButtonAsync(
group,
$"reschedule_session:{session.Id}",
session.BatchMessageId,
ct);
var prompt = await _client.WaitForBotReplyAsync(
group,
containsText: "Укажите 2-3 варианта времени",
timeout: TimeSpan.FromSeconds(30),
cancellationToken: ct)
?? throw new InvalidOperationException("Reschedule prompt was not received.");
var nowMoscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3));
var option1 = nowMoscow.AddMinutes(10);
var option2 = nowMoscow.AddMinutes(20);
var deadline = nowMoscow.AddMinutes(5);
var rescheduleText = string.Join(
"\n",
option1.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture),
option2.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture),
$"Дедлайн: {deadline.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture)}");
await _client.SendMessageAsync(group, rescheduleText, ct);
var voteMessage = await _client.WaitForBotReplyAsync(
group,
containsText: "Голосование за перенос",
timeout: TimeSpan.FromSeconds(30),
cancellationToken: ct)
?? throw new InvalidOperationException("Reschedule voting message was not received.");
var proposal = await WaitForActiveRescheduleProposalAsync(session.Id, ct);
var options = await _db.GetRescheduleOptionsAsync(proposal.Id);
var firstOption = options.FirstOrDefault()
?? throw new InvalidOperationException("No reschedule options found in the database.");
await _client.ClickInlineButtonAsync(
group,
$"reschedule_vote:{firstOption.Id}",
voteMessage.id,
ct);
Console.WriteLine($"[scenario] voted for option {firstOption.Id}; waiting for deadline service");
var finalDeadline = DateTime.UtcNow.AddMinutes(8);
while (DateTime.UtcNow < finalDeadline)
{
ct.ThrowIfCancellationRequested();
var updated = await _db.GetSessionByTitleAsync(group.Id, session.Title);
if (updated?.ScheduledAt >= option1.AddMinutes(-1) && updated?.ScheduledAt <= option1.AddMinutes(1))
{
Console.WriteLine("[scenario] reschedule applied");
break;
}
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
var afterReschedule = await _db.GetSessionByTitleAsync(group.Id, session.Title)
?? throw new InvalidOperationException("Session disappeared after reschedule.");
if (afterReschedule.Status != "Planned")
throw new InvalidOperationException($"Expected session status 'Planned' after reschedule, got '{afterReschedule.Status}'.");
Console.WriteLine("[scenario] waiting for T-24h confirmation request");
var confirmationMessageId = await WaitForNullableIntAsync(
async () => (await _db.GetSessionByTitleAsync(group.Id, session.Title))?.ConfirmationMessageId,
timeout: TimeSpan.FromMinutes(2),
ct);
await _client.ClickInlineButtonAsync(
group,
$"rsvp:confirm:{session.Id}",
confirmationMessageId,
ct);
await WaitForSessionStatusAsync(session.Id, "Confirmed", TimeSpan.FromMinutes(1), ct);
Console.WriteLine("[scenario] RSVP confirmed");
Console.WriteLine("[scenario] waiting for T-5m join link");
await WaitForBotMessageAsync(
group,
containsText: "начинается через 5 минут",
timeout: TimeSpan.FromMinutes(2),
ct);
var afterJoinLink = await _db.GetSessionByTitleAsync(group.Id, session.Title)
?? throw new InvalidOperationException("Session disappeared after join link.");
if (!afterJoinLink.LinkMessageId.HasValue)
throw new InvalidOperationException("Expected link_message_id to be populated after T-5m notification.");
Console.WriteLine("[scenario] T-24h confirmation and T-5m join link verified");
}
private async Task ManualPromoteAsync(ChatGroup group, SessionDto session, CancellationToken ct)
{
Console.WriteLine("[scenario] manual waitlist promotion");
await _db.SeedFakeParticipantAsync(
session.Id,
"E2E Fake Waitlisted",
externalUserId: 9000000001,
registrationStatus: "Waitlisted",
rsvpStatus: "Pending",
isGm: false);
await _db.UpdateSessionMaxPlayersAsync(session.Id, 2);
await _client.SendCommandAsync(group, "listsessions", ct);
var listMessage = await _client.WaitForBotReplyAsync(
group,
containsText: "Ближайшие игры",
timeout: TimeSpan.FromSeconds(30),
cancellationToken: ct)
?? throw new InvalidOperationException("/listsessions reply was not received.");
await _client.ClickInlineButtonAsync(
group,
$"promote_waitlist:{session.Id}",
listMessage.id,
ct);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
var fake = await FindParticipantAsync(session.Id, "9000000001", ct);
if (fake?.RegistrationStatus != "Active")
throw new InvalidOperationException("Expected fake waitlisted participant to be promoted to active.");
Console.WriteLine("[scenario] manual promotion verified");
}
private async Task AutoPromoteLeaveAsync(ChatGroup group, SessionDto session, CancellationToken ct)
{
Console.WriteLine("[scenario] leave + automatic waitlist promotion");
await _db.SeedFakeParticipantAsync(
session.Id,
"E2E Fake Promotion",
externalUserId: 9000000002,
registrationStatus: "Waitlisted",
rsvpStatus: "Pending",
isGm: false);
await _client.ClickInlineButtonAsync(
group,
$"leave_session:{session.Id}",
session.BatchMessageId,
ct);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
var me = await GetCurrentParticipantAsync(session.Id, ct);
if (me is not null)
throw new InvalidOperationException("Expected current user to be removed from session after leave.");
var fake = await FindParticipantAsync(session.Id, "9000000002", ct);
if (fake?.RegistrationStatus != "Active")
throw new InvalidOperationException("Expected fake waitlisted participant to be auto-promoted after leave.");
Console.WriteLine("[scenario] auto-promotion after leave verified");
}
private async Task JoinWaitlistAsync(ChatGroup group, SessionDto session, CancellationToken ct)
{
Console.WriteLine("[scenario] join to waitlist when capacity is full");
await _db.SeedFakeParticipantAsync(
session.Id,
"E2E Fake Active",
externalUserId: 9000000003,
registrationStatus: "Active",
rsvpStatus: "Pending",
isGm: false);
await _db.UpdateSessionMaxPlayersAsync(session.Id, 1);
await _client.ClickInlineButtonAsync(
group,
$"join_session:{session.Id}",
session.BatchMessageId,
ct);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
var me = await GetCurrentParticipantAsync(session.Id, ct);
if (me?.RegistrationStatus != "Waitlisted")
throw new InvalidOperationException("Expected current user to be waitlisted when capacity is full.");
Console.WriteLine("[scenario] waitlist join verified");
}
private async Task<ParticipantDto?> GetCurrentParticipantAsync(Guid sessionId, CancellationToken ct)
{
var participants = await _db.GetParticipantsAsync(sessionId);
return participants.FirstOrDefault(p =>
p.ExternalUserId == _client.CurrentUserId.ToString(CultureInfo.InvariantCulture));
}
private async Task<ParticipantDto?> FindParticipantAsync(Guid sessionId, string externalUserId, CancellationToken ct)
{
var participants = await _db.GetParticipantsAsync(sessionId);
return participants.FirstOrDefault(p => p.ExternalUserId == externalUserId);
}
private async Task<RescheduleProposalDto> WaitForActiveRescheduleProposalAsync(Guid sessionId, CancellationToken ct)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
var proposal = await _db.GetActiveRescheduleProposalAsync(sessionId);
if (proposal is not null)
return proposal;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
throw new TimeoutException("Reschedule proposal was not created in the database.");
}
private async Task WaitForSessionStatusAsync(
Guid sessionId,
string expectedStatus,
TimeSpan timeout,
CancellationToken ct)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
var session = await _db.GetSessionByIdAsync(sessionId);
if (session?.Status == expectedStatus)
return;
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
throw new TimeoutException($"Session did not reach status '{expectedStatus}' in time.");
}
private async Task WaitForBotMessageAsync(
ChatGroup group,
string containsText,
TimeSpan timeout,
CancellationToken ct)
{
var message = await _client.WaitForBotReplyAsync(
group,
containsText: containsText,
timeout: timeout,
cancellationToken: ct);
if (message is null)
throw new TimeoutException($"Bot message containing '{containsText}' was not received.");
}
private async Task<int> WaitForNullableIntAsync(
Func<Task<int?>> poll,
TimeSpan timeout,
CancellationToken ct)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
var value = await poll();
if (value.HasValue)
return value.Value;
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
throw new TimeoutException("Expected nullable int value was not populated in time.");
}
}
+6 -1
View File
@@ -10,6 +10,7 @@ var config = new RunnerConfig
PhoneNumber = Environment.GetEnvironmentVariable("phone_number")!,
BotUsername = Environment.GetEnvironmentVariable("TELEGRAM_BOT_USERNAME")!,
BotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!,
DatabaseUrl = Environment.GetEnvironmentVariable("GMRELAY_E2E_DATABASE_URL")!,
};
using var client = new TelegramUserClient(config);
@@ -30,7 +31,11 @@ try
JoinLink: "https://example.com/join-e2e");
await newSession.RunAsync(result.Group, inputs);
Console.WriteLine("Full scenario completed successfully.");
Console.WriteLine("Wizard scenario completed successfully.");
var lifecycle = new JoinLeaveWaitlistRescheduleScenario(client, config);
await lifecycle.RunAsync(result.Group);
Console.WriteLine("Lifecycle scenario completed successfully.");
}
catch (Exception ex)
{
+1
View File
@@ -7,4 +7,5 @@ public sealed class RunnerConfig
public required string PhoneNumber { get; init; }
public required string BotUsername { get; init; }
public required string BotToken { get; init; }
public required string DatabaseUrl { get; init; }
}
+2
View File
@@ -17,6 +17,8 @@ public sealed class TelegramUserClient : IDisposable
_client = new Client(_ => Environment.GetEnvironmentVariable(_));
}
public long CurrentUserId => _client.UserId;
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
var me = await _client.LoginUserIfNeeded();