""" Phase 0 tests: guest mode, new calculator variables, heat score, quote flow, migration. """ import math import pytest from padelnomics.leads.routes import calculate_heat_score from padelnomics.planner.calculator import DEFAULTS, calc, validate_state ALL_COMBOS = [ ("indoor", "rent"), ("indoor", "buy"), ("outdoor", "rent"), ("outdoor", "buy"), ] def default_state(**overrides): s = {**DEFAULTS, **overrides} return validate_state(s) def _assert_finite(obj, path=""): if isinstance(obj, float): assert math.isfinite(obj), f"Non-finite at {path}: {obj}" elif isinstance(obj, dict): for k, v in obj.items(): _assert_finite(v, f"{path}.{k}") elif isinstance(obj, list): for i, v in enumerate(obj): _assert_finite(v, f"{path}[{i}]") # ════════════════════════════════════════════════════════════ # Guest mode # ════════════════════════════════════════════════════════════ class TestGuestMode: async def test_planner_accessible_without_login(self, client): """GET /planner/ returns 200 for unauthenticated user.""" resp = await client.get("/en/planner/") assert resp.status_code == 200 async def test_calculate_endpoint_works_without_login(self, client): """POST /planner/calculate returns HTML partial for guest.""" resp = await client.post( "/en/planner/calculate", data={"dblCourts": "4", "activeTab": "capex"}, ) assert resp.status_code == 200 html = (await resp.data).decode() # HTMX endpoint returns an HTML partial containing CAPEX data assert "capex" in html.lower() or "metric-card" in html async def test_scenario_routes_require_login(self, client): """Save/load/delete/list scenarios still require auth.""" resp = await client.post( "/en/planner/scenarios/save", json={"name": "test", "state_json": "{}"}, ) assert resp.status_code in (302, 401) async def test_planner_hides_save_for_guest(self, client): """Planner HTML does not render scenario controls for guests.""" resp = await client.get("/en/planner/") html = (await resp.data).decode() assert "saveScenarioBtn" not in html async def test_planner_shows_save_for_auth(self, auth_client): """Planner HTML renders scenario controls for logged-in users.""" resp = await auth_client.get("/en/planner/") html = (await resp.data).decode() assert "saveScenarioBtn" in html # ════════════════════════════════════════════════════════════ # New calculator variables — defaults # ════════════════════════════════════════════════════════════ class TestNewCalculatorVariables: def test_budget_target_in_defaults(self): assert "budgetTarget" in DEFAULTS assert DEFAULTS["budgetTarget"] == 0 def test_glass_type_in_defaults(self): assert "glassType" in DEFAULTS assert DEFAULTS["glassType"] == "standard" def test_lighting_type_in_defaults(self): assert "lightingType" in DEFAULTS assert DEFAULTS["lightingType"] == "led_standard" # ════════════════════════════════════════════════════════════ # Glass type # ════════════════════════════════════════════════════════════ class TestGlassType: def test_panoramic_glass_increases_capex(self): d_std = calc(default_state(glassType="standard")) d_pan = calc(default_state(glassType="panoramic")) assert d_pan["capex"] > d_std["capex"] @pytest.mark.parametrize("glass", ["standard", "panoramic"]) @pytest.mark.parametrize("venue,own", ALL_COMBOS) def test_glass_type_all_combos(self, venue, own, glass): d = calc(default_state(venue=venue, own=own, glassType=glass)) assert d["capex"] > 0 _assert_finite(d) def test_panoramic_applies_1_4x_multiplier(self): """Panoramic courts cost 1.4x standard courts.""" d_std = calc(default_state(glassType="standard")) d_pan = calc(default_state(glassType="panoramic")) std_courts = next(i for i in d_std["capexItems"] if i["name"] == "Padel Courts") pan_courts = next(i for i in d_pan["capexItems"] if i["name"] == "Padel Courts") assert pan_courts["amount"] == pytest.approx(std_courts["amount"] * 1.4, abs=1) # ════════════════════════════════════════════════════════════ # Lighting type # ════════════════════════════════════════════════════════════ class TestLightingType: def test_led_competition_increases_capex(self): d_std = calc(default_state(lightingType="led_standard")) d_comp = calc(default_state(lightingType="led_competition")) assert d_comp["capex"] > d_std["capex"] def test_natural_light_outdoor(self): d_led = calc(default_state(venue="outdoor", lightingType="led_standard")) d_nat = calc(default_state(venue="outdoor", lightingType="natural")) assert d_nat["capex"] < d_led["capex"] def test_natural_light_zeroes_outdoor_lighting(self): d = calc(default_state(venue="outdoor", lightingType="natural")) lighting_item = next( (i for i in d["capexItems"] if i["name"] == "Lighting"), None ) assert lighting_item is not None assert lighting_item["amount"] == 0 @pytest.mark.parametrize("light", ["led_standard", "led_competition"]) @pytest.mark.parametrize("venue,own", ALL_COMBOS) def test_lighting_type_all_combos(self, venue, own, light): d = calc(default_state(venue=venue, own=own, lightingType=light)) assert d["capex"] > 0 _assert_finite(d) # ════════════════════════════════════════════════════════════ # Budget target # ════════════════════════════════════════════════════════════ class TestBudgetTarget: def test_budget_variance_when_set(self): d = calc(default_state(budgetTarget=300000)) assert "budgetVariance" in d assert d["budgetVariance"] == d["capex"] - 300000 def test_budget_variance_zero_when_no_budget(self): d = calc(default_state(budgetTarget=0)) assert d["budgetVariance"] == 0 assert d["budgetPct"] == 0 def test_budget_pct_calculated(self): d = calc(default_state(budgetTarget=200000)) assert d["budgetPct"] == pytest.approx(d["capex"] / 200000 * 100) def test_budget_target_passthrough(self): d = calc(default_state(budgetTarget=500000)) assert d["budgetTarget"] == 500000 # ════════════════════════════════════════════════════════════ # Heat score # ════════════════════════════════════════════════════════════ class TestHeatScore: def test_hot_lead(self): """High-readiness signals = hot.""" form = { "timeline": "asap", "location_status": "lease_signed", "financing_status": "self_funded", "decision_process": "solo", "previous_supplier_contact": "received_quotes", "budget_estimate": "500000", } assert calculate_heat_score(form) == "hot" def test_cool_lead(self): """Low-readiness signals = cool.""" form = { "timeline": "12+mo", "location_status": "still_searching", "financing_status": "not_started", "decision_process": "committee", "previous_supplier_contact": "first_time", "budget_estimate": "0", } assert calculate_heat_score(form) == "cool" def test_warm_lead(self): """Mid-readiness signals = warm.""" form = { "timeline": "3-6mo", "location_status": "location_found", "financing_status": "seeking", "decision_process": "partners", "budget_estimate": "150000", } assert calculate_heat_score(form) == "warm" def test_empty_form_is_cool(self): assert calculate_heat_score({}) == "cool" def test_timeline_6_12mo_scores_1(self): assert calculate_heat_score({"timeline": "6-12mo"}) == "cool" def test_high_budget_alone_not_hot(self): """Budget alone shouldn't make a lead hot.""" assert calculate_heat_score({"budget_estimate": "1000000"}) == "cool" def test_permit_granted_scores_4(self): assert calculate_heat_score({"location_status": "permit_granted"}) == "cool" # Combined with timeline makes it warm form = {"location_status": "permit_granted", "timeline": "asap"} assert calculate_heat_score(form) == "warm" def test_permit_pending_scores_3(self): form = { "location_status": "permit_pending", "timeline": "3-6mo", "financing_status": "self_funded", } assert calculate_heat_score(form) == "warm" def test_converting_existing_scores_2(self): assert calculate_heat_score({"location_status": "converting_existing"}) == "cool" def test_permit_not_filed_scores_2(self): assert calculate_heat_score({"location_status": "permit_not_filed"}) == "cool" def test_location_found_scores_1(self): assert calculate_heat_score({"location_status": "location_found"}) == "cool" # ════════════════════════════════════════════════════════════ # Quote request route # ════════════════════════════════════════════════════════════ class TestQuoteRequest: async def test_quote_form_loads(self, client): """GET /leads/quote returns 200 with wizard shell.""" resp = await client.get("/en/leads/quote") assert resp.status_code == 200 async def test_quote_prefill_from_params(self, client): """Query params pre-fill the form and start on step 2.""" resp = await client.get("/en/leads/quote?venue=outdoor&courts=6") assert resp.status_code == 200 async def test_quote_step_endpoint(self, client): """GET /leads/quote/step/1 returns 200 partial.""" resp = await client.get("/en/leads/quote/step/1") assert resp.status_code == 200 async def test_quote_step_invalid(self, client): """GET /leads/quote/step/0 returns 400.""" resp = await client.get("/en/leads/quote/step/0") assert resp.status_code == 400 async def test_quote_step_post_advances(self, client): """POST to step 1 with valid data returns step 2.""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote/step/1", form={ "_accumulated": "{}", "facility_type": "indoor", "court_count": "4", "csrf_token": csrf, }, ) assert resp.status_code == 200 html = (await resp.data).decode() assert "Location" in html async def test_quote_submit_creates_lead(self, client, db): """POST /leads/quote creates a lead_requests row as pending_verification.""" # Get CSRF token first await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote", form={ "facility_type": "indoor", "court_count": "4", "glass_type": "panoramic", "lighting_type": "led_standard", "build_context": "new_standalone", "country": "DE", "timeline": "3-6mo", "location_status": "location_found", "financing_status": "self_funded", "decision_process": "solo", "stakeholder_type": "entrepreneur", "contact_name": "Test User", "contact_email": "test@example.com", "contact_phone": "+491234567890", "csrf_token": csrf, }, ) assert resp.status_code == 200 async with db.execute("SELECT * FROM lead_requests WHERE lead_type = 'quote'") as cur: rows = await cur.fetchall() assert len(rows) == 1 row = dict(rows[0]) assert row["status"] == "pending_verification" assert row["heat_score"] in ("hot", "warm", "cool") assert row["contact_email"] == "test@example.com" assert row["facility_type"] == "indoor" assert row["stakeholder_type"] == "entrepreneur" async def test_quote_submit_without_login(self, client, db): """Guests get a user created and linked; lead is pending_verification.""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote", form={ "facility_type": "indoor", "court_count": "2", "country": "DE", "timeline": "3-6mo", "stakeholder_type": "entrepreneur", "contact_name": "Guest", "contact_email": "guest@example.com", "contact_phone": "+491234567890", "csrf_token": csrf, }, ) assert resp.status_code == 200 async with db.execute( "SELECT user_id, status FROM lead_requests WHERE contact_email = 'guest@example.com'" ) as cur: row = await cur.fetchone() assert row is not None assert row[0] is not None # user_id linked via get-or-create assert row[1] == "pending_verification" async def test_quote_submit_with_login(self, auth_client, db, test_user): """Logged-in user with matching email skips verification (status='new').""" await auth_client.get("/en/leads/quote") async with auth_client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await auth_client.post( "/en/leads/quote", form={ "facility_type": "outdoor", "court_count": "6", "country": "DE", "timeline": "asap", "stakeholder_type": "entrepreneur", "contact_name": "Auth User", "contact_email": "test@example.com", # matches test_user email "contact_phone": "+491234567890", "csrf_token": csrf, }, ) assert resp.status_code == 200 async with db.execute( "SELECT user_id, status FROM lead_requests WHERE contact_email = 'test@example.com'" ) as cur: row = await cur.fetchone() assert row is not None assert row[0] == test_user["id"] assert row[1] == "new" async def test_venue_search_build_context(self, client, db): """Build context 'venue_search' is stored correctly.""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote", form={ "facility_type": "indoor", "court_count": "4", "build_context": "venue_search", "country": "DE", "timeline": "6-12mo", "stakeholder_type": "developer", "contact_name": "Venue Search", "contact_email": "venue@example.com", "contact_phone": "+491234567890", "csrf_token": csrf, }, ) assert resp.status_code == 200 async with db.execute( "SELECT build_context FROM lead_requests WHERE contact_email = 'venue@example.com'" ) as cur: row = await cur.fetchone() assert row is not None assert row[0] == "venue_search" async def test_stakeholder_type_stored(self, client, db): """stakeholder_type field is stored correctly.""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote", form={ "facility_type": "indoor", "court_count": "6", "country": "DE", "timeline": "asap", "stakeholder_type": "tennis_club", "contact_name": "Club Owner", "contact_email": "club@example.com", "contact_phone": "+491234567890", "csrf_token": csrf, }, ) assert resp.status_code == 200 async with db.execute( "SELECT stakeholder_type FROM lead_requests WHERE contact_email = 'club@example.com'" ) as cur: row = await cur.fetchone() assert row is not None assert row[0] == "tennis_club" async def test_submitted_page_has_context(self, client): """Guest quote submission shows 'check your email' verify page.""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote", form={ "facility_type": "indoor", "court_count": "6", "country": "DE", "timeline": "3-6mo", "stakeholder_type": "entrepreneur", "contact_name": "Context Test", "contact_email": "ctx@example.com", "contact_phone": "+491234567890", "csrf_token": csrf, }, ) assert resp.status_code == 200 html = (await resp.data).decode() assert "check your email" in html.lower() assert "ctx@example.com" in html async def test_quote_validation_rejects_missing_fields(self, client): """POST /leads/quote returns 422 JSON when mandatory fields missing.""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await client.post( "/en/leads/quote", json={ "facility_type": "indoor", "court_count": "4", "contact_name": "", "contact_email": "", }, headers={"X-CSRF-Token": csrf}, ) assert resp.status_code == 422 data = await resp.get_json() assert data["ok"] is False assert len(data["errors"]) >= 3 # country, timeline, stakeholder_type + name + email + phone # ════════════════════════════════════════════════════════════ # Quote verification (double opt-in) # ════════════════════════════════════════════════════════════ class TestQuoteVerification: """Double opt-in email verification for quote requests.""" QUOTE_FORM = { "facility_type": "indoor", "court_count": "4", "country": "DE", "timeline": "3-6mo", "stakeholder_type": "entrepreneur", "contact_name": "Verify Test", "contact_email": "verify@example.com", "contact_phone": "+491234567890", } async def _submit_guest_quote(self, client, db, email="verify@example.com"): """Helper: submit a quote as a guest, return (lead_id, auth_token, lead_token).""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") form = {**self.QUOTE_FORM, "contact_email": email, "csrf_token": csrf} await client.post("/en/leads/quote", form=form) async with db.execute( "SELECT id, token FROM lead_requests WHERE contact_email = ?", (email,) ) as cur: row = await cur.fetchone() lead_id, lead_token = row[0], row[1] async with db.execute( "SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1" ) as cur: auth_token = (await cur.fetchone())[0] return lead_id, auth_token, lead_token async def test_guest_quote_creates_pending_lead(self, client, db): """Guest quote creates lead with status='pending_verification'.""" lead_id, _, _lt = await self._submit_guest_quote(client, db) async with db.execute( "SELECT status FROM lead_requests WHERE id = ?", (lead_id,) ) as cur: row = await cur.fetchone() assert row[0] == "pending_verification" async def test_logged_in_same_email_skips_verification(self, auth_client, db, test_user): """Logged-in user with matching email gets status='new' and 'matched' page.""" await auth_client.get("/en/leads/quote") async with auth_client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await auth_client.post( "/en/leads/quote", form={ **self.QUOTE_FORM, "contact_email": "test@example.com", # matches test_user "csrf_token": csrf, }, ) assert resp.status_code == 200 html = (await resp.data).decode() assert "matched" in html.lower() async with db.execute( "SELECT status FROM lead_requests WHERE contact_email = 'test@example.com'" ) as cur: row = await cur.fetchone() assert row[0] == "new" async def test_logged_in_different_email_needs_verification(self, auth_client, db, test_user): """Logged-in user with different email still needs verification.""" await auth_client.get("/en/leads/quote") async with auth_client.session_transaction() as sess: csrf = sess.get("csrf_token", "") resp = await auth_client.post( "/en/leads/quote", form={ **self.QUOTE_FORM, "contact_email": "other@example.com", # different from test_user "csrf_token": csrf, }, ) assert resp.status_code == 200 html = (await resp.data).decode() assert "check your email" in html.lower() async with db.execute( "SELECT status FROM lead_requests WHERE contact_email = 'other@example.com'" ) as cur: row = await cur.fetchone() assert row[0] == "pending_verification" async def test_verify_link_activates_lead(self, client, db): """GET /leads/verify with valid token sets status='new' and verified_at.""" lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) resp = await client.get(f"/en/leads/verify?token={auth_token}&lead={lead_token}") assert resp.status_code == 200 async with db.execute( "SELECT status, verified_at FROM lead_requests WHERE id = ?", (lead_id,) ) as cur: row = await cur.fetchone() assert row[0] == "new" assert row[1] is not None # verified_at timestamp set async def test_verify_sets_session(self, client, db): """Verification link logs the user in (sets session user_id).""" _lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) await client.get(f"/en/leads/verify?token={auth_token}&lead={lead_token}") async with client.session_transaction() as sess: assert "user_id" in sess async def test_verify_expired_token(self, client, db): """Expired/used token redirects with error.""" _lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) # Expire the token await db.execute("UPDATE auth_tokens SET expires_at = '2000-01-01T00:00:00'") await db.commit() resp = await client.get( f"/en/leads/verify?token={auth_token}&lead={lead_token}", follow_redirects=False, ) assert resp.status_code == 302 async def test_verify_already_verified_lead(self, client, db): """Attempting to verify an already-activated lead shows error.""" lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) # Manually activate the lead await db.execute( "UPDATE lead_requests SET status = 'new' WHERE id = ?", (lead_id,) ) await db.commit() resp = await client.get( f"/en/leads/verify?token={auth_token}&lead={lead_token}", follow_redirects=False, ) assert resp.status_code == 302 async def test_verify_missing_params(self, client, db): """Missing token or lead params redirects.""" resp = await client.get("/en/leads/verify", follow_redirects=False) assert resp.status_code == 302 resp = await client.get("/en/leads/verify?token=abc", follow_redirects=False) assert resp.status_code == 302 async def test_guest_quote_creates_user(self, client, db): """Guest quote submission creates a user row for the contact email.""" await self._submit_guest_quote(client, db, email="newuser@example.com") async with db.execute( "SELECT id FROM users WHERE email = 'newuser@example.com'" ) as cur: row = await cur.fetchone() assert row is not None # ════════════════════════════════════════════════════════════ # Migration / schema # ════════════════════════════════════════════════════════════ class TestSchema: async def test_schema_has_new_columns(self, db): """Fresh DB from schema.sql has all expanded lead_requests columns.""" async with db.execute("PRAGMA table_info(lead_requests)") as cur: cols = {r[1] for r in await cur.fetchall()} for expected in ( "facility_type", "glass_type", "lighting_type", "build_context", "country", "timeline", "location_status", "financing_status", "heat_score", "contact_name", "contact_email", "contact_phone", "contact_company", "wants_financing_help", "decision_process", "previous_supplier_contact", "services_needed", "additional_info", "stakeholder_type", "verified_at", ): assert expected in cols, f"Missing column: {expected}" async def test_user_id_nullable(self, db): """lead_requests.user_id should accept NULL for guest leads.""" await db.execute( "INSERT INTO lead_requests (lead_type, contact_email, created_at) VALUES (?, ?, datetime('now'))", ("quote", "guest@example.com"), ) await db.commit() async with db.execute( "SELECT user_id FROM lead_requests WHERE contact_email = 'guest@example.com'" ) as cur: row = await cur.fetchone() assert row[0] is None