Files
padelnomics/web/tests/test_e2e_flows.py
Deeman e35e01edb1 test: add 18 e2e tests for billing, checkout, supplier signup/dashboard, export
- Pricing page (EN/DE, plan cards, no-auth access)
- Checkout success (auth required, renders for authed user)
- Supplier signup wizard (step 1, plan cards, DE variant, success page)
- Supplier dashboard (overview stats, boosts/credit packs, listing, leads tabs)
- Business plan export (auth required, form renders)

Also fixes:
- E2e server init_db mock scope — before_serving was calling real init_db
  outside the patch context, overwriting the in-memory DB (fixes 3
  pre-existing failures: markets_hub, markets_results, signup_page)
- Add _seed_billing_data() for supplier + feature flags in e2e server
- Mock RESEND_API_KEY="" in conftest + e2e server to prevent real emails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:34:39 +01:00

741 lines
28 KiB
Python

"""
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 /<lang>/."""
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 <h1> or <h2> — 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 "<urlset" in page.content() or "<sitemapindex" in page.content()
def test_health_endpoint(live_server, page):
resp = page.goto(live_server + "/health")
assert resp.ok
# =============================================================================
# B. Planner Flow
# =============================================================================
def test_planner_en_loads(live_server, page):
"""Planner page renders wizard and tab bar."""
resp = page.goto(live_server + "/en/planner/")
assert resp.ok
# Tab bar
expect(page.locator("#nav")).to_be_visible()
# Wizard form
expect(page.locator("#planner-form")).to_be_visible()
def test_planner_de_loads(live_server, page):
"""German planner renders with German UI strings."""
resp = page.goto(live_server + "/de/planner/")
assert resp.ok
# Should contain a German-language label somewhere
content = page.content()
assert "Investition" in content or "Annahmen" in content or "Anlage" in content
def test_planner_calculate_htmx(live_server, page):
"""Clicking a result tab fires HTMX and shows the results panel."""
page.goto(live_server + "/en/planner/")
page.wait_for_load_state("networkidle")
# #tab-content starts display:none; clicking a result tab makes it visible
capex_btn = page.locator("button[data-tab='capex'], [data-tab='capex']").first
capex_btn.click()
page.wait_for_timeout(1000)
tab_content = page.locator("#tab-content")
expect(tab_content).to_be_visible()
assert len(tab_content.inner_html()) > 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 /<lang>/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()