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 <noreply@anthropic.com>
This commit is contained in:
+64
-54
@@ -1,71 +1,81 @@
|
|||||||
# GmRelay E2E Tests
|
# 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;
|
## Status
|
||||||
- `api_id` / `api_hash` from https://my.telegram.org;
|
|
||||||
- a pre-authenticated MTProto session;
|
Tracked as a Gitea milestone: [E2E Automation](https://git.codeanddice.ru/toutsu/GmRelayBot/issues?state=open&milestone=...) <!-- update milestone link manually -->
|
||||||
- a running PostgreSQL, GmRelay.Bot, and GmRelay.Web.
|
|
||||||
|
| 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
|
## Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
tests/e2e/
|
tests/e2e/
|
||||||
├── README.md # this file
|
├── README.md
|
||||||
|
├── requirements.txt
|
||||||
|
├── .gitignore
|
||||||
├── helpers/
|
├── helpers/
|
||||||
│ ├── telegram_init_data.py # generate valid Telegram initData / Login Widget payloads
|
│ ├── telegram_init_data.py # Build valid Telegram auth payloads
|
||||||
│ └── test_telegram_init_data.py # unit tests for the helper
|
│ ├── test_telegram_init_data.py # Self-contained sanity tests for the helper
|
||||||
└── ... (runner and scenarios will land here)
|
│ └── __init__.py
|
||||||
|
└── dashboard/
|
||||||
|
├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard
|
||||||
|
└── __init__.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## `telegram_init_data.py`
|
## Install dependencies
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tests/e2e/helpers
|
python -m venv .venv
|
||||||
python -m pytest test_telegram_init_data.py -v
|
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)
|
## Run Playwright dashboard tests
|
||||||
- [#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
|
1. Start the Web dashboard (and PostgreSQL) locally. The fastest way:
|
||||||
- [#147](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/147) Group creation automation
|
```bash
|
||||||
- [#148](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/148) `/newsession` scenario
|
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
|
||||||
- [#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
|
2. Export environment variables that match the running Web instance:
|
||||||
- [#151](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/151) Console runner + cleanup
|
```bash
|
||||||
|
export GMRELAY_E2E_BASE_URL="http://localhost:8080"
|
||||||
|
export GMRELAY_E2E_BOT_TOKEN="<same-token-as-web>"
|
||||||
|
export GMRELAY_E2E_TELEGRAM_ID="9000000001"
|
||||||
|
export GMRELAY_E2E_DATABASE_URL="Host=localhost;Database=gmrelay;Username=postgres;Password=<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.
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
playwright>=1.44.0
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
Reference in New Issue
Block a user