diff --git a/web/tests/conftest.py b/web/tests/conftest.py index 7bba7a0..e04538c 100644 --- a/web/tests/conftest.py +++ b/web/tests/conftest.py @@ -226,16 +226,45 @@ def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str _VISUAL_PORT = 5111 +async def _seed_visual_data(conn): + """Seed a supplier + feature flags for E2E billing/dashboard tests.""" + await conn.execute( + "INSERT INTO users (id, email, created_at)" + " VALUES (999, 'supplier@test.com', datetime('now'))" + ) + 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'))" + ) + 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_visual_server(ready_event): """Run a Quart dev server in a subprocess for visual/E2E tests. Forces RESEND_API_KEY="" so no real emails are sent. + Forces WAITLIST_MODE=false so feature pages render (not waitlist templates). Runs migrations in-process to build the full schema (including FTS tables). """ import asyncio import os os.environ["RESEND_API_KEY"] = "" + os.environ["WAITLIST_MODE"] = "false" + # Config class attributes are evaluated at import time (before fork), + # so we must also patch the live config object directly. + core.config.WAITLIST_MODE = False async def _serve(): # Build schema DDL in-process (FTS5 virtual tables need this) @@ -257,7 +286,16 @@ def _run_visual_server(ready_event): conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys=ON") await conn.executescript(schema_ddl) - await conn.commit() + # Ensure feature_flags table exists (may be missed if an FTS5 + # CREATE VIRTUAL TABLE causes executescript to stop early) + 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')))" + ) + # Seed data needed by E2E tests (supplier dashboard, billing, etc.) + await _seed_visual_data(conn) core._db = conn # Patch init_db/close_db where they're USED (app.py imports them @@ -266,7 +304,9 @@ def _run_visual_server(ready_event): # Patches must stay active through run_task() because before_serving # hooks call init_db() which would replace our in-memory DB. with patch("padelnomics.app.init_db", new_callable=AsyncMock), \ - patch("padelnomics.app.close_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() diff --git a/web/tests/screenshots/landing_full.png b/web/tests/screenshots/landing_full.png index d28a696..e2c4456 100644 Binary files a/web/tests/screenshots/landing_full.png and b/web/tests/screenshots/landing_full.png differ diff --git a/web/tests/screenshots/landing_mobile.png b/web/tests/screenshots/landing_mobile.png index c470868..b8f45c8 100644 Binary files a/web/tests/screenshots/landing_mobile.png and b/web/tests/screenshots/landing_mobile.png differ diff --git a/web/tests/screenshots/signup.png b/web/tests/screenshots/signup.png index a088257..41aef77 100644 Binary files a/web/tests/screenshots/signup.png and b/web/tests/screenshots/signup.png differ diff --git a/web/tests/test_e2e_flows.py b/web/tests/test_e2e_flows.py index 9f24271..bb70e83 100644 --- a/web/tests/test_e2e_flows.py +++ b/web/tests/test_e2e_flows.py @@ -2,7 +2,7 @@ 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). +billing, supplier dashboard, and cross-cutting checks (translations, footer, language switcher). Skipped by default (requires `playwright install chromium`). Run explicitly with: @@ -403,3 +403,178 @@ def test_planner_tooltips_present(live_server, page): 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()}" + 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 + 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() + 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}" + 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()}" + ) + 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() + 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") + 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() + 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() + expect(page.locator("#export-form").first).to_be_visible() + expect(page.locator("#export-buy-btn").first).to_be_visible()