refactor: flatten padelnomics/padelnomics/ → repo root
git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
507
web/tests/test_e2e_flows.py
Normal file
507
web/tests/test_e2e_flows.py
Normal file
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
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 import core
|
||||
from padelnomics.app import create_app
|
||||
from padelnomics.migrations.migrate import migrate
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
pytestmark = pytest.mark.visual
|
||||
|
||||
PORT = 5113
|
||||
BASE = f"http://127.0.0.1:{PORT}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Server / Browser Fixtures
|
||||
# =============================================================================
|
||||
|
||||
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)
|
||||
await conn.commit()
|
||||
core._db = conn
|
||||
|
||||
with patch.object(core, "init_db", new_callable=AsyncMock), \
|
||||
patch.object(core, "close_db", new_callable=AsyncMock):
|
||||
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("#tab-bar")).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):
|
||||
"""Adjusting a planner input fires HTMX and updates the results panel."""
|
||||
page.goto(live_server + "/en/planner/")
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Wait for tab content to be present
|
||||
tab_content = page.locator("#tab-content")
|
||||
expect(tab_content).to_be_visible()
|
||||
|
||||
# Trigger a change on a number input to fire HTMX recalc
|
||||
first_input = page.locator("#planner-form input[type='number']").first
|
||||
first_input.click()
|
||||
first_input.press("ArrowUp")
|
||||
|
||||
# Wait for HTMX response to update tab-content
|
||||
page.wait_for_timeout(800)
|
||||
# Tab content should still exist and be non-empty after recalc
|
||||
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, page):
|
||||
"""Quote sidebar should be visible on wide viewport (>1400px)."""
|
||||
pg = page.context.new_page()
|
||||
pg.set_viewport_size({"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")).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")).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_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")).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 language switcher link for DE
|
||||
de_link = page.locator("a[href*='/de/'], a[href='/de']").first
|
||||
if de_link.count() == 0:
|
||||
pytest.skip("No DE language switcher link found")
|
||||
|
||||
de_link.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
assert "/de/" in page.url or page.url.endswith("/de"), 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"
|
||||
Reference in New Issue
Block a user