"""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}"