add supplier tiers, directory redesign, CTA cleanup, and ROI fix
Phase 0 features: ungate planner, lead qualifier with heat scoring, quote form (migrations 0002-0003), supplier directory with FTS5 search (migration 0004), landing page redesign with ROI calculator and FAQ. Phase 1 improvements: supplier tier system with Growth/Pro paid plans (migration 0005), HTMX live directory search, three-tier card design, Zillow-style sticky nav, "Get Matched" → "Get Quotes" CTA rename, remove "Free" messaging site-wide, realistic ROI calculator defaults (~3.9yr payback / ~26% ROI), mandatory form validation with 422 errors, supplier pricing page with boost add-ons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -476,6 +476,55 @@ class TestCalcIndoorRent:
|
||||
assert "Water" in names
|
||||
assert "Cleaning" in names
|
||||
|
||||
def test_permits_compliance_in_indoor_rent(self, d):
|
||||
names = [i["name"] for i in d["capexItems"]]
|
||||
assert "Permits & Compliance" in names
|
||||
|
||||
def test_permits_compliance_amount(self, d):
|
||||
permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
|
||||
assert permits["amount"] == DEFAULTS["permitsCompliance"]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# calc — permits & compliance across scenarios
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPermitsCompliance:
|
||||
def test_indoor_rent_has_permits(self):
|
||||
d = calc(default_state(venue="indoor", own="rent"))
|
||||
names = [i["name"] for i in d["capexItems"]]
|
||||
assert "Permits & Compliance" in names
|
||||
|
||||
def test_outdoor_rent_has_permits(self):
|
||||
d = calc(default_state(venue="outdoor", own="rent"))
|
||||
names = [i["name"] for i in d["capexItems"]]
|
||||
assert "Permits & Compliance" in names
|
||||
|
||||
def test_outdoor_buy_has_permits(self):
|
||||
d = calc(default_state(venue="outdoor", own="buy"))
|
||||
names = [i["name"] for i in d["capexItems"]]
|
||||
assert "Permits & Compliance" in names
|
||||
|
||||
def test_indoor_buy_no_permits_compliance(self):
|
||||
"""Indoor Buy already has Planning + Permits, so no separate Permits & Compliance."""
|
||||
d = calc(default_state(venue="indoor", own="buy"))
|
||||
names = [i["name"] for i in d["capexItems"]]
|
||||
assert "Permits & Compliance" not in names
|
||||
assert "Planning + Permits" in names
|
||||
|
||||
def test_permits_compliance_value_adjustable(self):
|
||||
d = calc(default_state(venue="indoor", own="rent", permitsCompliance=25000))
|
||||
permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
|
||||
assert permits["amount"] == 25000
|
||||
|
||||
def test_country_in_defaults(self):
|
||||
assert "country" in DEFAULTS
|
||||
assert DEFAULTS["country"] == "DE"
|
||||
|
||||
def test_permits_compliance_in_defaults(self):
|
||||
assert "permitsCompliance" in DEFAULTS
|
||||
assert DEFAULTS["permitsCompliance"] == 12000
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# calc — edge cases
|
||||
@@ -758,7 +807,7 @@ class TestCalcRegression:
|
||||
return calc(default_state())
|
||||
|
||||
def test_capex_value(self, d):
|
||||
assert d["capex"] == 270380
|
||||
assert d["capex"] == 283580
|
||||
|
||||
def test_total_courts(self, d):
|
||||
assert d["totalCourts"] == 6
|
||||
@@ -981,6 +1030,11 @@ plausible_state = st.fixed_dictionaries({
|
||||
"exitMultiple": st.floats(0, 20, allow_nan=False, allow_infinity=False),
|
||||
"contingencyPct": st.integers(0, 30),
|
||||
"staff": st.integers(0, 20000),
|
||||
"budgetTarget": st.integers(0, 5000000),
|
||||
"glassType": st.sampled_from(["standard", "panoramic"]),
|
||||
"lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
|
||||
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "OTHER"]),
|
||||
"permitsCompliance": st.integers(0, 50000),
|
||||
})
|
||||
|
||||
|
||||
|
||||
505
padelnomics/tests/test_phase0.py
Normal file
505
padelnomics/tests/test_phase0.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Phase 0 tests: guest mode, new calculator variables, heat score, quote flow, migration.
|
||||
"""
|
||||
import json
|
||||
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("/planner/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_calculate_endpoint_works_without_login(self, client):
|
||||
"""POST /planner/calculate returns valid JSON for guest."""
|
||||
resp = await client.post(
|
||||
"/planner/calculate",
|
||||
json={"state": {"dblCourts": 4}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_json()
|
||||
assert "capex" in data
|
||||
|
||||
async def test_scenario_routes_require_login(self, client):
|
||||
"""Save/load/delete/list scenarios still require auth."""
|
||||
resp = await client.post(
|
||||
"/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("/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("/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 form."""
|
||||
resp = await client.get("/leads/quote")
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_quote_prefill_from_params(self, client):
|
||||
"""Query params pre-fill the form."""
|
||||
resp = await client.get("/leads/quote?venue=outdoor&courts=6")
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_quote_submit_creates_lead(self, client, db):
|
||||
"""POST /leads/quote creates a lead_requests row."""
|
||||
# Get CSRF token first
|
||||
await client.get("/leads/quote")
|
||||
async with client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await client.post(
|
||||
"/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",
|
||||
"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["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 can submit quotes (user_id is null)."""
|
||||
await client.get("/leads/quote")
|
||||
async with client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await client.post(
|
||||
"/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",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async with db.execute(
|
||||
"SELECT user_id FROM lead_requests WHERE contact_email = 'guest@example.com'"
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] is None # user_id should be NULL for guests
|
||||
|
||||
async def test_quote_submit_with_login(self, auth_client, db, test_user):
|
||||
"""Logged-in user gets user_id set on lead."""
|
||||
await auth_client.get("/leads/quote")
|
||||
async with auth_client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/leads/quote",
|
||||
form={
|
||||
"facility_type": "outdoor",
|
||||
"court_count": "6",
|
||||
"country": "DE",
|
||||
"timeline": "asap",
|
||||
"stakeholder_type": "entrepreneur",
|
||||
"contact_name": "Auth User",
|
||||
"contact_email": "auth@example.com",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async with db.execute(
|
||||
"SELECT user_id FROM lead_requests WHERE contact_email = 'auth@example.com'"
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == test_user["id"]
|
||||
|
||||
async def test_venue_search_build_context(self, client, db):
|
||||
"""Build context 'venue_search' is stored correctly."""
|
||||
await client.get("/leads/quote")
|
||||
async with client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await client.post(
|
||||
"/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",
|
||||
"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("/leads/quote")
|
||||
async with client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await client.post(
|
||||
"/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",
|
||||
"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):
|
||||
"""Quote submitted page includes project context."""
|
||||
await client.get("/leads/quote")
|
||||
async with client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await client.post(
|
||||
"/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",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.data).decode()
|
||||
assert "matched" in html.lower()
|
||||
assert "6-court" in html
|
||||
assert "DE" 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("/leads/quote")
|
||||
async with client.session_transaction() as sess:
|
||||
csrf = sess.get("csrf_token", "")
|
||||
|
||||
resp = await client.post(
|
||||
"/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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 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",
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Business plan price in config
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestBusinessPlanConfig:
|
||||
def test_business_plan_in_paddle_prices(self):
|
||||
from padelnomics.core import Config
|
||||
c = Config()
|
||||
assert "business_plan" in c.PADDLE_PRICES
|
||||
Reference in New Issue
Block a user