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:
+23
-12
@@ -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")
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user