""" Comprehensive E2E flow tests using Playwright. Covers all major user flows: public pages, planner, auth, directory, quote wizard, and cross-cutting checks (translations, footer, language switcher). Skipped by default (requires `playwright install chromium`). Run explicitly with: uv run pytest -m visual tests/test_e2e_flows.py -v Server runs on port 5113 (isolated from test_visual.py on 5111 and test_quote_wizard.py on 5112). """ import asyncio import multiprocessing import sqlite3 import tempfile import time from pathlib import Path from unittest.mock import AsyncMock, patch import pytest from padelnomics.app import create_app from padelnomics.migrations.migrate import migrate from playwright.sync_api import expect, sync_playwright from padelnomics import core pytestmark = pytest.mark.visual PORT = 5113 BASE = f"http://127.0.0.1:{PORT}" # ============================================================================= # Server / Browser Fixtures # ============================================================================= async def _seed_billing_data(conn): """Seed a supplier + feature flags for e2e billing tests.""" # Create user for supplier dashboard await conn.execute( "INSERT INTO users (id, email, created_at)" " VALUES (999, 'supplier@test.com', datetime('now'))" ) # Create pro-tier supplier claimed by that user await conn.execute( "INSERT INTO suppliers" " (id, name, slug, tier, claimed_by, claimed_at," " country_code, region, category, credit_balance," " monthly_credits, contact_name, contact_email, created_at)" " VALUES (1, 'Test Supplier GmbH', 'test-supplier', 'pro', 999," " datetime('now'), 'DE', 'Europe', 'construction', 50," " 10, 'Test User', 'supplier@test.com', datetime('now'))" ) # Enable feature flags needed for billing/signup/export tests for flag in ("supplier_signup", "markets", "payments", "planner_export", "lead_unlock"): await conn.execute( "INSERT OR REPLACE INTO feature_flags (name, enabled)" " VALUES (?, 1)", (flag,) ) await conn.commit() def _run_server(ready_event): """Run the Quart dev server in a subprocess with in-memory SQLite.""" import aiosqlite async def _serve(): tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db") migrate(tmp_db) tmp_conn = sqlite3.connect(tmp_db) rows = tmp_conn.execute( "SELECT sql FROM sqlite_master" " WHERE sql IS NOT NULL" " AND name NOT LIKE 'sqlite_%'" " AND name NOT LIKE '%_fts_%'" " AND name != '_migrations'" " ORDER BY rowid" ).fetchall() tmp_conn.close() schema_ddl = ";\n".join(r[0] for r in rows) + ";" conn = await aiosqlite.connect(":memory:") conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys=ON") await conn.executescript(schema_ddl) # Safety: ensure feature_flags table exists (may be missed by # executescript if an earlier DDL statement involving FTS5 fails) await conn.execute( "CREATE TABLE IF NOT EXISTS feature_flags" " (name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0," " description TEXT," " updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')))" ) await _seed_billing_data(conn) core._db = conn # Never send real emails from the e2e server core.config.RESEND_API_KEY = "" # Patch init_db/close_db at the *app* module level (where before_serving # imports them via `from .core import init_db`). Patching core.init_db # alone won't affect the already-bound reference in app.py's globals. with patch("padelnomics.app.init_db", new_callable=AsyncMock), \ patch("padelnomics.app.close_db", new_callable=AsyncMock), \ patch("padelnomics.app.open_analytics_db"), \ patch("padelnomics.app.close_analytics_db"): app = create_app() app.config["TESTING"] = True ready_event.set() await app.run_task(host="127.0.0.1", port=PORT) asyncio.run(_serve()) @pytest.fixture(scope="module") def live_server(): ready = multiprocessing.Event() proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True) proc.start() ready.wait(timeout=10) time.sleep(1) yield BASE proc.terminate() proc.join(timeout=5) @pytest.fixture(scope="module") def browser(): with sync_playwright() as p: b = p.chromium.launch(headless=True) yield b b.close() @pytest.fixture def page(browser): pg = browser.new_page(viewport={"width": 1440, "height": 900}) yield pg pg.close() @pytest.fixture def mobile_page(browser): pg = browser.new_page(viewport={"width": 390, "height": 844}) yield pg pg.close() def dev_login(page, base, email="test@example.com"): """Instantly authenticate via dev-login endpoint.""" page.goto(f"{base}/auth/dev-login?email={email}") page.wait_for_load_state("networkidle") # ============================================================================= # A. Public Pages — smoke test every public GET returns 200 # ============================================================================= def test_root_redirects_to_lang(live_server, page): """GET / should redirect to //.""" resp = page.goto(live_server + "/") assert resp.ok, f"/ returned {resp.status}" assert "/en/" in page.url or "/de/" in page.url, f"No lang in URL: {page.url}" @pytest.mark.parametrize("path", [ "/en/", "/de/", "/en/features", "/en/terms", "/en/privacy", "/en/about", "/en/imprint", "/en/suppliers", "/de/features", "/de/about", "/de/suppliers", ]) def test_public_page_200(live_server, page, path): """Every public page should return 200 and contain meaningful content.""" resp = page.goto(live_server + path) assert resp.ok, f"{path} returned {resp.status}" # Verify page has

or

— not a blank error page heading = page.locator("h1, h2").first expect(heading).to_be_visible() def test_robots_txt(live_server, page): resp = page.goto(live_server + "/robots.txt") assert resp.ok assert "User-agent" in page.content() def test_sitemap_xml(live_server, page): resp = page.goto(live_server + "/sitemap.xml") assert resp.ok assert " 100 def test_planner_tab_switching(live_server, page): """Clicking all result tabs renders different content each time.""" page.goto(live_server + "/en/planner/") page.wait_for_load_state("networkidle") tabs = ["capex", "operating", "cashflow", "returns", "metrics"] seen_contents = set() for tab_id in tabs: btn = page.locator(f"button[data-tab='{tab_id}'], [data-tab='{tab_id}']").first if btn.count() == 0: # Try clicking by visible text btn = page.get_by_role("button", name=tab_id, exact=False).first btn.click() page.wait_for_timeout(600) html = page.locator("#tab-content").inner_html() seen_contents.add(html[:100]) # first 100 chars as fingerprint # All tabs should render distinct content assert len(seen_contents) >= 3, "Tab content didn't change across tabs" def test_planner_chart_data_present(live_server, page): """Result tabs embed chart data as JSON script tags.""" page.goto(live_server + "/en/planner/") page.wait_for_load_state("networkidle") # Charts on the capex tab chart_scripts = page.locator("script[type='application/json']") assert chart_scripts.count() >= 1, "No chart JSON script tags found" def test_planner_quote_sidebar_visible_wide(live_server, browser): """Quote sidebar should be visible on wide viewport (>1400px).""" pg = browser.new_page(viewport={"width": 1600, "height": 900}) pg.goto(live_server + "/en/planner/") pg.wait_for_load_state("networkidle") sidebar = pg.locator(".quote-sidebar") if sidebar.count() > 0: # If present, should not be display:none display = pg.evaluate( "getComputedStyle(document.querySelector('.quote-sidebar')).display" ) assert display != "none", f"Quote sidebar is hidden on wide viewport: display={display}" pg.close() # ============================================================================= # C. Auth Flow # ============================================================================= def test_login_page_loads(live_server, page): resp = page.goto(live_server + "/auth/login") assert resp.ok expect(page.locator("form").first).to_be_visible() expect(page.locator("input[type='email']")).to_be_visible() def test_signup_page_loads(live_server, page): resp = page.goto(live_server + "/auth/signup") assert resp.ok expect(page.locator("form").first).to_be_visible() def test_dev_login_redirects_to_dashboard(live_server, page): """Dev login should create session and redirect to dashboard.""" page.goto(live_server + "/auth/dev-login?email=devtest@example.com") page.wait_for_load_state("networkidle") assert "/dashboard" in page.url, f"Expected dashboard redirect, got: {page.url}" def test_authenticated_dashboard_loads(live_server, page): """After dev-login, dashboard should be accessible.""" dev_login(page, live_server, "dash@example.com") assert "/dashboard" in page.url resp_status = page.evaluate("() => document.readyState") assert resp_status == "complete" def test_dashboard_quote_link_goes_to_wizard(live_server, page): """Dashboard 'request quote' button should link to the quote wizard, not suppliers page.""" dev_login(page, live_server, "dashquote@example.com") href = page.locator("a", has_text="Quote").first.get_attribute("href") assert "/leads/quote" in href, f"Expected quote wizard link, got: {href}" assert "suppliers" not in href, f"Link should not point to suppliers page: {href}" def test_unauthenticated_dashboard_redirects(live_server, page): """Without auth, /dashboard/ should redirect to login.""" page.goto(live_server + "/dashboard/", wait_until="networkidle") assert "login" in page.url or "auth" in page.url, ( f"Expected redirect to login, got: {page.url}" ) def test_magic_link_sent_page(live_server, page): """Magic link sent page renders with the email address shown.""" resp = page.goto(live_server + "/auth/magic-link-sent?email=test@example.com") assert resp.ok expect(page.get_by_text("test@example.com")).to_be_visible() # ============================================================================= # D. Directory Flow # ============================================================================= def test_directory_en_loads(live_server, page): resp = page.goto(live_server + "/en/directory/") assert resp.ok expect(page.locator("h1, h2").first).to_be_visible() def test_directory_de_loads(live_server, page): resp = page.goto(live_server + "/de/directory/") assert resp.ok content = page.content() # Should have German-language UI (filter label, heading, etc.) assert "Lieferanten" in content or "Anbieter" in content or "Kategorie" in content or resp.ok def test_directory_search_htmx(live_server, page): """Typing in directory search fires HTMX and returns results partial.""" page.goto(live_server + "/en/directory/") page.wait_for_load_state("networkidle") search = page.locator("input[type='search'], input[name='q'], input[type='text']").first if search.count() == 0: pytest.skip("No search input found") search.fill("padel") page.wait_for_timeout(600) # Results container should exist results = page.locator("#supplier-results, #results, [id*='result']").first expect(results).to_be_visible() # ============================================================================= # E. Quote Flow (key steps) # ============================================================================= def test_quote_step1_loads(live_server, page): """Quote wizard step 1 renders the form.""" resp = page.goto(live_server + "/en/leads/quote") assert resp.ok expect(page.locator("form").first).to_be_visible() def test_quote_step1_de_loads(live_server, page): resp = page.goto(live_server + "/de/leads/quote") assert resp.ok content = page.content() # Should have at least some German text assert "Anlage" in content or "Platz" in content or "Projekt" in content or resp.ok def test_quote_verify_url_includes_lang(live_server, page): """Verify the leads/verify route exists at //leads/verify.""" # GET with no token should redirect to login or show error — but should NOT 404 resp = page.goto(live_server + "/en/leads/verify?token=invalid") # Should be 200 (shows error) or a redirect — not 404 assert resp.status != 404, "Verify endpoint returned 404 — lang prefix missing?" # ============================================================================= # F. Authenticated Flows (planner scenarios, leads forms) # ============================================================================= def test_authenticated_planner_loads(live_server, page): """Authenticated user can access the planner.""" dev_login(page, live_server, "planneruser@example.com") resp = page.goto(live_server + "/en/planner/") assert resp.ok expect(page.locator("#planner-form")).to_be_visible() def test_planner_scenarios_list(live_server, page): """Authenticated user can access scenarios list.""" dev_login(page, live_server, "scenuser@example.com") resp = page.goto(live_server + "/en/planner/scenarios") assert resp.ok # ============================================================================= # G. Cross-Cutting Checks # ============================================================================= def test_footer_present_on_public_pages(live_server, page): """Footer should be present on all public pages.""" for path in ["/en/", "/en/features", "/en/about"]: page.goto(live_server + path) footer = page.locator("footer") expect(footer).to_be_visible() def test_footer_has_four_column_layout(live_server, page): """Footer grid should have 4 link columns.""" page.goto(live_server + "/en/") page.wait_for_load_state("networkidle") # Count footer navigation columns (divs/sections with link lists) footer_cols = page.evaluate(""" (() => { const footer = document.querySelector('footer'); if (!footer) return 0; const grid = footer.querySelector('[class*="grid"]'); if (!grid) return 0; return grid.children.length; })() """) assert footer_cols >= 4, f"Expected 4 footer columns, found {footer_cols}" def test_language_switcher_en_to_de(live_server, page): """Language switcher should navigate from /en/ to /de/.""" page.goto(live_server + "/en/") page.wait_for_load_state("networkidle") # Find the footer language switcher link to /de/ de_link = page.locator("a[href='/de/']").first if de_link.count() == 0: pytest.skip("No DE language switcher link found") href = de_link.get_attribute("href") assert href and "/de" in href, f"DE link has unexpected href: {href}" # Navigate directly to verify the German page loads page.goto(live_server + "/de/") page.wait_for_load_state("networkidle") assert "/de/" in page.url, f"Language switch failed: {page.url}" def test_no_missing_translations_en(live_server, page): """EN pages should not contain 'None' or 'undefined' translation markers.""" for path in ["/en/", "/en/features", "/en/about"]: page.goto(live_server + path) content = page.locator("body").inner_text() # Check for obvious translation failure markers assert "t.auth_" not in content, f"Untranslated key in {path}" assert "{{" not in content, f"Jinja template not rendered in {path}" def test_no_missing_translations_de(live_server, page): """DE pages should contain German text, not raw English keys.""" page.goto(live_server + "/de/") content = page.locator("body").inner_text() assert "{{" not in content, "Jinja template not rendered in /de/" assert "t.auth_" not in content, "Untranslated key in /de/" def test_auth_login_page_german(live_server, page): """Auth login should render in German when lang cookie is 'de'.""" page.context.add_cookies([{ "name": "lang", "value": "de", "domain": "127.0.0.1", "path": "/" }]) page.goto(live_server + "/auth/login") content = page.content() # German auth page should contain German text assert "Anmelden" in content or "E-Mail" in content or "Weiter" in content def test_404_for_nonexistent_page(live_server, page): """Non-existent pages should return 404.""" resp = page.goto(live_server + "/en/this-page-does-not-exist-xyz") assert resp.status == 404, f"Expected 404, got {resp.status}" def test_legacy_redirect_terms(live_server, page): """Legacy /terms should redirect to /en/terms.""" resp = page.goto(live_server + "/terms") # Either 301/302 redirect or 200 at /en/terms assert resp.ok or resp.status in (301, 302) # ============================================================================= # H. Markets Waitlist (WAITLIST_MODE=False by default — page should load) # ============================================================================= def test_markets_hub_loads(live_server, page): """Markets hub should load normally when WAITLIST_MODE is off.""" resp = page.goto(live_server + "/en/markets") assert resp.ok expect(page.locator("h1, h2").first).to_be_visible() def test_markets_results_partial_loads(live_server, page): """Markets results HTMX partial should return 200.""" resp = page.goto(live_server + "/en/markets/results") assert resp.ok # ============================================================================= # I. Tooltip Presence (result tab tooltips) # ============================================================================= def test_planner_tooltips_present(live_server, page): """Result tabs should contain tooltip spans for complex financial terms.""" page.goto(live_server + "/en/planner/") page.wait_for_load_state("networkidle") # The returns tab should have tooltip spans returns_btn = page.locator("button[data-tab='returns'], [data-tab='returns']").first if returns_btn.count() > 0: returns_btn.click() page.wait_for_timeout(600) # After clicking returns tab, look for tooltip info spans ti_spans = page.locator(".ti") assert ti_spans.count() >= 1, "No tooltip spans (.ti) found on results tab" # ============================================================================= # J. Pricing Page # ============================================================================= def test_pricing_page_loads_en(live_server, page): """Pricing page renders in English.""" resp = page.goto(live_server + "/billing/pricing") assert resp.ok, f"/billing/pricing returned {resp.status}" expect(page.locator("h1").first).to_be_visible() def test_pricing_page_loads_de(live_server, page): """Pricing page renders in German when lang cookie is set.""" page.context.add_cookies([{ "name": "lang", "value": "de", "domain": "127.0.0.1", "path": "/" }]) resp = page.goto(live_server + "/billing/pricing") assert resp.ok, f"/billing/pricing returned {resp.status}" expect(page.locator("h1").first).to_be_visible() def test_pricing_page_has_plan_cards(live_server, page): """Pricing page shows at least 2 plan cards with CTAs.""" page.goto(live_server + "/billing/pricing") page.wait_for_load_state("networkidle") cards = page.locator(".card-header") assert cards.count() >= 2, f"Expected >=2 plan cards, found {cards.count()}" # Each card should have a CTA button/link ctas = page.locator("a.btn, a.btn-outline") assert ctas.count() >= 2, f"Expected >=2 CTAs, found {ctas.count()}" def test_pricing_page_no_auth_required(live_server, page): """Pricing page is accessible without authentication.""" resp = page.goto(live_server + "/billing/pricing") assert resp.ok # Should NOT have redirected to login assert "login" not in page.url and "auth" not in page.url # ============================================================================= # K. Checkout Success # ============================================================================= def test_checkout_success_requires_auth(live_server, page): """Checkout success page redirects unauthenticated users to login.""" page.goto(live_server + "/billing/success", wait_until="networkidle") assert "login" in page.url or "auth" in page.url, ( f"Expected redirect to login, got: {page.url}" ) def test_checkout_success_renders_for_authed_user(live_server, page): """Checkout success page renders for authenticated user.""" dev_login(page, live_server, "checkout@example.com") resp = page.goto(live_server + "/billing/success") assert resp.ok expect(page.locator("h1").first).to_be_visible() # Should have a CTA button (back to dashboard or similar) expect(page.locator("a.btn").first).to_be_visible() # ============================================================================= # L. Supplier Signup Wizard # ============================================================================= def test_supplier_signup_step1_loads(live_server, page): """Supplier signup wizard step 1 renders.""" resp = page.goto(live_server + "/en/suppliers/signup") assert resp.ok, f"/en/suppliers/signup returned {resp.status}" # Wait for HTMX to load step content page.wait_for_load_state("networkidle") expect(page.locator("[data-step='1']").first).to_be_visible() def test_supplier_signup_step1_has_plan_cards(live_server, page): """Step 1 shows plan cards for selecting a tier.""" page.goto(live_server + "/en/suppliers/signup") page.wait_for_load_state("networkidle") plan_cards = page.locator(".s-plan-card") assert plan_cards.count() >= 2, ( f"Expected >=2 plan cards, found {plan_cards.count()}" ) # Should have a next button expect(page.locator(".s-btn-next").first).to_be_visible() def test_supplier_signup_step1_de_loads(live_server, page): """German variant of supplier signup wizard loads.""" resp = page.goto(live_server + "/de/suppliers/signup") assert resp.ok, f"/de/suppliers/signup returned {resp.status}" page.wait_for_load_state("networkidle") expect(page.locator("[data-step='1']").first).to_be_visible() def test_supplier_signup_success_page_loads(live_server, page): """Supplier signup success page renders.""" resp = page.goto(live_server + "/en/suppliers/signup/success") assert resp.ok, f"signup success returned {resp.status}" expect(page.locator("h1, h2").first).to_be_visible() # ============================================================================= # M. Supplier Dashboard (seeded supplier data) # ============================================================================= def test_supplier_dashboard_loads(live_server, page): """Dashboard loads for authenticated supplier.""" dev_login(page, live_server, "supplier@test.com") resp = page.goto(live_server + "/en/suppliers/dashboard") assert resp.ok, f"dashboard returned {resp.status}" expect(page.locator(".dash").first).to_be_visible() # Sidebar should show supplier name expect(page.locator(".dash-sidebar__name").first).to_be_visible() def test_supplier_dashboard_overview_tab(live_server, page): """Overview tab renders with stats grid.""" dev_login(page, live_server, "supplier@test.com") page.goto(live_server + "/en/suppliers/dashboard") page.wait_for_load_state("networkidle") # Wait for HTMX to load overview content page.wait_for_timeout(1000) stats = page.locator(".ov-stat") assert stats.count() >= 2, f"Expected >=2 stat cards, found {stats.count()}" def test_supplier_dashboard_boosts_tab(live_server, page): """Boosts tab renders with boost options.""" dev_login(page, live_server, "supplier@test.com") resp = page.goto(live_server + "/en/suppliers/dashboard/boosts") assert resp.ok, f"boosts tab returned {resp.status}" def test_supplier_dashboard_boosts_has_credit_packs(live_server, page): """Boosts tab shows credit pack purchase options.""" dev_login(page, live_server, "supplier@test.com") page.goto(live_server + "/en/suppliers/dashboard/boosts") page.wait_for_load_state("networkidle") content = page.content() # Credit pack amounts should be visible (25, 50, 100, 250) assert "25" in content or "50" in content or "100" in content, ( "No credit pack amounts found on boosts page" ) def test_supplier_dashboard_listing_tab(live_server, page): """Listing tab renders for authenticated supplier.""" dev_login(page, live_server, "supplier@test.com") resp = page.goto(live_server + "/en/suppliers/dashboard/listing") assert resp.ok, f"listing tab returned {resp.status}" def test_supplier_dashboard_leads_tab(live_server, page): """Leads tab renders for pro-tier supplier.""" dev_login(page, live_server, "supplier@test.com") resp = page.goto(live_server + "/en/suppliers/dashboard/leads") assert resp.ok, f"leads tab returned {resp.status}" # ============================================================================= # N. Business Plan Export # ============================================================================= def test_export_page_requires_auth(live_server, page): """Export page redirects unauthenticated users to login.""" page.goto(live_server + "/en/planner/export", wait_until="networkidle") assert "login" in page.url or "auth" in page.url, ( f"Expected redirect to login, got: {page.url}" ) def test_export_page_loads_for_authed_user(live_server, page): """Export page renders for authenticated user with form.""" dev_login(page, live_server, "export@example.com") resp = page.goto(live_server + "/en/planner/export") assert resp.ok, f"export page returned {resp.status}" expect(page.locator("h1").first).to_be_visible() # Should have the export form with scenario select and buy button expect(page.locator("#export-form").first).to_be_visible() expect(page.locator("#export-buy-btn").first).to_be_visible()