tests/e2e/test_app_e2e.py
Rama dev · réplica de lectura del repositorio original.
"""E2E: the shipped SPA, in a real browser, against the real API and a fake Plex server.
This is the layer that catches cross-boundary contract breaks — a missing CSRF header or a
mismatched request body passes both unit suites and dies here.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
from tests.e2e.conftest import ShortlistApp
pytestmark = pytest.mark.e2e
class TestAppLoads:
def test_dashboard_renders_the_report_from_the_api(self, page: Page, app: ShortlistApp):
# The dashboard IS the tracking report now (the per-user list moved to the Users page).
# Before anything has run it shows the first-run panel — which still proves the /api/report
# round-trip rendered: the panel is chosen FROM the report (no finished run, no first pick),
# and a failed fetch would show the error state instead.
page.goto("/")
expect(page.get_by_role("heading", name="Build everyone\u2019s rows for the first time")).to_be_visible(
timeout=20_000
)
def test_no_console_errors(self, page: Page, app: ShortlistApp):
errors: list[str] = []
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.goto("/")
expect(page.get_by_role("heading", name="Build everyone\u2019s rows for the first time")).to_be_visible(
timeout=20_000
)
page.wait_for_load_state("networkidle")
assert not errors, errors
class TestMutationContract:
"""A mutation issued BY THE SPA must reach the backend and actually take effect.
Regression cover for the two HIGH review findings: the missing `x-shortlist-csrf` header
(every UI mutation 403'd) and the uninstall body that never carried its confirmation.
The browser context deliberately does not inject the header — the app must send it.
"""
def test_user_toggle_from_the_ui_persists(self, page: Page, app: ShortlistApp):
page.goto("/users")
toggle = page.get_by_role("switch").first
expect(toggle).to_be_visible(timeout=20_000)
before = toggle.is_checked()
responses: list[int] = []
page.on("response", lambda r: responses.append(r.status) if "/api/users/" in r.url else None)
with page.expect_response(lambda r: "/api/users/" in r.url and r.request.method == "PATCH"):
toggle.click()
assert responses, "the toggle issued no PATCH to /api/users/{id}"
assert 403 not in responses, "the SPA's mutation was rejected — CSRF header missing"
assert 200 in responses, f"unexpected statuses from the PATCH: {responses}"
# The change must survive a reload — proof it reached the database, not just the UI.
page.reload()
expect(page.get_by_role("switch").first).to_be_visible(timeout=20_000)
assert page.get_by_role("switch").first.is_checked() is not before
def test_no_spa_mutation_leaves_without_the_csrf_header(self, page: Page, app: ShortlistApp):
missing: list[str] = []
def check(request) -> None:
is_mutation = request.method not in ("GET", "HEAD", "OPTIONS") and "/api/" in request.url
if is_mutation and not request.header_value("x-shortlist-csrf"):
missing.append(f"{request.method} {request.url}")
page.on("request", check)
page.goto("/users")
expect(page.get_by_role("switch").first).to_be_visible(timeout=20_000)
with page.expect_response(lambda r: "/api/users/" in r.url and r.request.method == "PATCH"):
page.get_by_role("switch").first.click()
assert not missing, f"SPA sent mutations without the CSRF header: {missing}"