diff --git a/web/tests/conftest.py b/web/tests/conftest.py index e5c1d49..c2a7450 100644 --- a/web/tests/conftest.py +++ b/web/tests/conftest.py @@ -189,6 +189,7 @@ def patch_config(): test_values = { "PADDLE_API_KEY": "test_api_key_123", "PADDLE_WEBHOOK_SECRET": "whsec_test_secret", + "RESEND_API_KEY": "", # never send real emails in tests "BASE_URL": "http://localhost:5000", "DEBUG": True, "WAITLIST_MODE": False, diff --git a/web/tests/test_e2e_flows.py b/web/tests/test_e2e_flows.py index 2f06b3e..2765978 100644 --- a/web/tests/test_e2e_flows.py +++ b/web/tests/test_e2e_flows.py @@ -36,6 +36,33 @@ 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 @@ -59,16 +86,32 @@ def _run_server(ready_event): conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys=ON") await conn.executescript(schema_ddl) - await conn.commit() + # 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 - with patch.object(core, "init_db", new_callable=AsyncMock), \ - patch.object(core, "close_db", new_callable=AsyncMock): + # 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) + ready_event.set() + await app.run_task(host="127.0.0.1", port=PORT) asyncio.run(_serve()) @@ -511,3 +554,187 @@ def test_planner_tooltips_present(live_server, page): # 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()