From fcc85148473b744ce6949e55cd63e171e227da76 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 16 Jun 2026 11:59:36 +0300 Subject: [PATCH] feat(e2e): #145 Playwright dashboard tests with mock Telegram auth - Add Playwright-based E2E tests in tests/e2e/dashboard/ - Authenticate via /auth/telegram-webapp using helpers/telegram_init_data.py - Cover dashboard load and session edit flow - Add requirements.txt and package dashboard folder - Update README with setup and test descriptions Co-Authored-By: Claude Opus 4.8 --- tests/e2e/README.md | 118 ++++---- tests/e2e/dashboard/__init__.py | 0 .../test_dashboard_auth_and_sessions.py | 251 ++++++++++++++++++ tests/e2e/helpers/__init__.py | 0 tests/e2e/requirements.txt | 2 + 5 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 tests/e2e/dashboard/__init__.py create mode 100644 tests/e2e/dashboard/test_dashboard_auth_and_sessions.py create mode 100644 tests/e2e/helpers/__init__.py create mode 100644 tests/e2e/requirements.txt diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 8d6e605..d71c02e 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,71 +1,81 @@ # GmRelay E2E Tests -This directory contains end-to-end tests that run **locally** against real Telegram infrastructure and the GmRelay Web dashboard. They are intentionally **not part of CI** because they require: +This module contains locally-run end-to-end tests for the GmRelay Telegram bot and Blazor/Web dashboard. +It is deliberately **not** wired into CI because it requires real Telegram infrastructure (MTProto user client) and a running Web instance. -- a real Telegram test user account; -- `api_id` / `api_hash` from https://my.telegram.org; -- a pre-authenticated MTProto session; -- a running PostgreSQL, GmRelay.Bot, and GmRelay.Web. +## Status + +Tracked as a Gitea milestone: [E2E Automation](https://git.codeanddice.ru/toutsu/GmRelayBot/issues?state=open&milestone=...) + +| Issue | Title | Status | +|-------|-------|--------| +| #144 | initData / Login Widget helper for mock Telegram auth | ✅ Done | +| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | 🚧 In progress | +| #146 | Telegram user client (MTProto) | ⏳ Planned | +| #147 | Automate group creation and bot invitation | ⏳ Planned | +| #148 | Scenario: /newsession from creation to publication | ⏳ Planned | +| #149 | Join/leave, waitlist, reschedule and notification scenarios | ⏳ Planned | +| #150 | Dashboard display and editing verification | ⏳ Planned | +| #151 | Console runner and cleanup | ⏳ Planned | ## Structure ```text tests/e2e/ -├── README.md # this file +├── README.md +├── requirements.txt +├── .gitignore ├── helpers/ -│ ├── telegram_init_data.py # generate valid Telegram initData / Login Widget payloads -│ └── test_telegram_init_data.py # unit tests for the helper -└── ... (runner and scenarios will land here) +│ ├── telegram_init_data.py # Build valid Telegram auth payloads +│ ├── test_telegram_init_data.py # Self-contained sanity tests for the helper +│ └── __init__.py +└── dashboard/ + ├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard + └── __init__.py ``` -## `telegram_init_data.py` - -A small Python helper that produces Telegram Mini App `initData` and Login Widget payloads with valid HMAC-SHA256 signatures. It mirrors `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`. - -Use it to open the Blazor/Mini App dashboard in Playwright without logging into Telegram: - -```python -from helpers.telegram_init_data import build_mini_app_init_data - -init = build_mini_app_init_data( - bot_token="YOUR_BOT_TOKEN", - telegram_id=424242, - first_name="Test", - username="tester") - -await page.goto(f"https://localhost:8080/#tgWebAppData={init.init_data_raw}") -``` - -## C# equivalent - -For tests inside the solution, use `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`: - -```csharp -var init = TelegramAuthPayloadBuilder.BuildMiniAppInitData( - botToken: "YOUR_BOT_TOKEN", - telegramId: 424242L, - firstName: "Test", - username: "tester"); - -// init.InitDataRaw is a valid initData string. -``` - -## Running the helper tests +## Install dependencies ```bash -cd tests/e2e/helpers -python -m pytest test_telegram_init_data.py -v +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r tests/e2e/requirements.txt +playwright install chromium ``` -## Roadmap +## Run helper tests -See the Gitea milestone **[Этап — E2E-тестирование Telegram + Web](https://git.codeanddice.ru/Toutsu/GmRelayBot/milestone/13)** and its issues: +```bash +python tests/e2e/helpers/test_telegram_init_data.py +``` -- [#144](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/144) initData helper ✅ (this directory) -- [#145](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/145) Playwright dashboard tests -- [#146](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/146) MTProto test user client -- [#147](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/147) Group creation automation -- [#148](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/148) `/newsession` scenario -- [#149](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/149) join/leave/waitlist/reschedule scenarios -- [#150](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/150) Web dashboard round-trip verification -- [#151](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/151) Console runner + cleanup +## Run Playwright dashboard tests + +1. Start the Web dashboard (and PostgreSQL) locally. The fastest way: + ```bash + dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj + ``` +2. Export environment variables that match the running Web instance: + ```bash + export GMRELAY_E2E_BASE_URL="http://localhost:8080" + export GMRELAY_E2E_BOT_TOKEN="" + export GMRELAY_E2E_TELEGRAM_ID="9000000001" + export GMRELAY_E2E_DATABASE_URL="Host=localhost;Database=gmrelay;Username=postgres;Password=" + ``` +3. Run the tests: + ```bash + python tests/e2e/dashboard/test_dashboard_auth_and_sessions.py + ``` + +## What the dashboard tests cover + +- `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. + +## 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. +- For headful debugging, change `headless=True` to `headless=False` in the test file. diff --git a/tests/e2e/dashboard/__init__.py b/tests/e2e/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/dashboard/test_dashboard_auth_and_sessions.py b/tests/e2e/dashboard/test_dashboard_auth_and_sessions.py new file mode 100644 index 0000000..cae0470 --- /dev/null +++ b/tests/e2e/dashboard/test_dashboard_auth_and_sessions.py @@ -0,0 +1,251 @@ +""" +Playwright E2E tests for the GmRelay Blazor/Web dashboard. + +These tests use a mocked Telegram Mini App initData payload so they can run +locally against a real GmRelay.Web instance without talking to Telegram. + +Prerequisites: + pip install playwright + playwright install chromium + +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 +""" + +from __future__ import annotations + +import os +import time +import urllib.parse +from typing import Optional + +from playwright.sync_api import Page, expect, sync_playwright + +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from helpers.telegram_init_data import build_mini_app_init_data + + +def _base_url() -> str: + return os.environ.get("GMRELAY_E2E_BASE_URL", "http://localhost:8080").rstrip("/") + + +def _bot_token() -> str: + token = os.environ.get("GMRELAY_E2E_BOT_TOKEN") + if not token: + raise RuntimeError( + "GMRELAY_E2E_BOT_TOKEN is required. It must match the bot token used by the target Web instance." + ) + return token + + +def _telegram_id() -> int: + return int(os.environ.get("GMRELAY_E2E_TELEGRAM_ID", "9000000001")) + + +def _seed_group_in_database( + telegram_id: int, + group_id: str, + group_name: str, + session_id: str, + session_title: str, +) -> None: + """ + Insert minimal test data via the shared Npgsql connection so the dashboard + has something to render. This is intentionally thin: full state setup is + performed by the console runner in issue #151. + """ + import psycopg2 + + dsn = os.environ.get("GMRELAY_E2E_DATABASE_URL") + if not dsn: + return # rely on a pre-seeded dev database + + platform = "Telegram" + 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)) + + with psycopg2.connect(dsn) as conn: + with conn.cursor() as cur: + cur.execute( + """ + 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 + RETURNING id + """, + (platform, external_user_id, "E2E Test GM", now), + ) + player_id = cur.fetchone()[0] + + cur.execute( + """ + INSERT INTO gmrelay.game_groups (id, platform, external_group_id, telegram_chat_id, name, created_at) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name + RETURNING id + """, + (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 + """, + (group_id, platform, external_user_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 + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title + """, + ( + session_id, + group_id, + session_title, + scheduled_at, + "https://example.com/join", + "Planned", + "None", + "GroupAndDirect", + 5, + now, + ), + ) + cur.execute( + """ + INSERT INTO gmrelay.session_players (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 + """, + (session_id, player_id, "Active", "Pending", True, now), + ) + + conn.commit() + + +def _delete_test_data(group_id: str, session_id: str) -> None: + import psycopg2 + + dsn = os.environ.get("GMRELAY_E2E_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("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,)) + conn.commit() + + +def _authenticate_page(page: Page, base_url: str, bot_token: str, telegram_id: int) -> None: + """ + Authenticate by calling /auth/telegram-webapp with a valid initData payload. + The response sets the auth cookie; subsequent page navigations are authenticated. + """ + init_data = build_mini_app_init_data( + bot_token=bot_token, + telegram_id=telegram_id, + first_name="E2E", + last_name="Test", + username="e2e_test", + ) + + response = page.request.post( + f"{base_url}/auth/telegram-webapp", + data={"initData": init_data.init_data_raw}, + ) + expect(response).to_be_ok() + page.goto(base_url) + + +def _wait_for_blazor(page: Page) -> None: + """Wait until Blazor has finished the initial render.""" + page.wait_for_selector("text=Добро пожаловать", timeout=15000) + + +def test_dashboard_authenticates_and_shows_groups() -> None: + base_url = _base_url() + bot_token = _bot_token() + telegram_id = _telegram_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) + + heading = page.locator("h2") + expect(heading).to_contain_text("Добро пожаловать") + + browser.close() + + +def test_dashboard_session_edit_flow() -> None: + base_url = _base_url() + bot_token = _bot_token() + telegram_id = _telegram_id() + + group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" + session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa1" + original_title = "E2E Original Title" + updated_title = "E2E Updated Title" + + try: + _seed_group_in_database( + telegram_id=telegram_id, + group_id=group_id, + group_name="E2E Test Group", + session_id=session_id, + session_title=original_title, + ) + + 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={original_title}", timeout=15000) + + 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.fill(updated_title) + + 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() + + browser.close() + finally: + _delete_test_data(group_id, session_id) + + +if __name__ == "__main__": + test_dashboard_authenticates_and_shows_groups() + print("PASS test_dashboard_authenticates_and_shows_groups") + test_dashboard_session_edit_flow() + print("PASS test_dashboard_session_edit_flow") diff --git a/tests/e2e/helpers/__init__.py b/tests/e2e/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/requirements.txt b/tests/e2e/requirements.txt new file mode 100644 index 0000000..bb6dc16 --- /dev/null +++ b/tests/e2e/requirements.txt @@ -0,0 +1,2 @@ +playwright>=1.44.0 +psycopg2-binary>=2.9.9