add double opt-in email verification for quote requests

Guest quote submissions now require email verification before the lead
goes live. The verification click also creates a user account and logs
them in. Logged-in users submitting with their own email skip verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-17 17:02:32 +01:00
parent e0563d62ff
commit 6a10f82b5d
8 changed files with 620 additions and 52 deletions

View File

@@ -253,17 +253,46 @@ class TestHeatScore:
class TestQuoteRequest:
async def test_quote_form_loads(self, client):
"""GET /leads/quote returns 200 with form."""
"""GET /leads/quote returns 200 with wizard shell."""
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."""
"""Query params pre-fill the form and start on step 2."""
resp = await client.get("/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("/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("/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("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/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."""
"""POST /leads/quote creates a lead_requests row as pending_verification."""
# Get CSRF token first
await client.get("/leads/quote")
async with client.session_transaction() as sess:
@@ -294,13 +323,14 @@ class TestQuoteRequest:
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 can submit quotes (user_id is null)."""
"""Guests get a user created and linked; lead is pending_verification."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
@@ -321,14 +351,15 @@ class TestQuoteRequest:
assert resp.status_code == 200
async with db.execute(
"SELECT user_id FROM lead_requests WHERE contact_email = 'guest@example.com'"
"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 None # user_id should be NULL for guests
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 gets user_id set on lead."""
"""Logged-in user with matching email skips verification (status='new')."""
await auth_client.get("/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
@@ -342,18 +373,19 @@ class TestQuoteRequest:
"timeline": "asap",
"stakeholder_type": "entrepreneur",
"contact_name": "Auth User",
"contact_email": "auth@example.com",
"contact_email": "test@example.com", # matches test_user email
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT user_id FROM lead_requests WHERE contact_email = 'auth@example.com'"
"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."""
@@ -413,7 +445,7 @@ class TestQuoteRequest:
assert row[0] == "tennis_club"
async def test_submitted_page_has_context(self, client):
"""Quote submitted page includes project context."""
"""Guest quote submission shows 'check your email' verify page."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
@@ -433,9 +465,8 @@ class TestQuoteRequest:
)
assert resp.status_code == 200
html = (await resp.data).decode()
assert "matched" in html.lower()
assert "6-court" in html
assert "DE" in html
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."""
@@ -459,6 +490,173 @@ class TestQuoteRequest:
assert len(data["errors"]) >= 3 # country, timeline, stakeholder_type + name + email
# ════════════════════════════════════════════════════════════
# 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",
}
async def _submit_guest_quote(self, client, db, email="verify@example.com"):
"""Helper: submit a quote as a guest, return (lead_id, token)."""
await client.get("/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("/leads/quote", form=form)
async with db.execute(
"SELECT id FROM lead_requests WHERE contact_email = ?", (email,)
) as cur:
lead_id = (await cur.fetchone())[0]
async with db.execute(
"SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1"
) as cur:
token = (await cur.fetchone())[0]
return lead_id, token
async def test_guest_quote_creates_pending_lead(self, client, db):
"""Guest quote creates lead with status='pending_verification'."""
lead_id, _ = 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("/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/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("/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/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, token = await self._submit_guest_quote(client, db)
resp = await client.get(f"/leads/verify?token={token}&lead={lead_id}")
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, token = await self._submit_guest_quote(client, db)
await client.get(f"/leads/verify?token={token}&lead={lead_id}")
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, 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"/leads/verify?token={token}&lead={lead_id}",
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, 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"/leads/verify?token={token}&lead={lead_id}",
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("/leads/verify", follow_redirects=False)
assert resp.status_code == 302
resp = await client.get("/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
# ════════════════════════════════════════════════════════════
@@ -476,7 +674,7 @@ class TestSchema:
"contact_phone", "contact_company",
"wants_financing_help", "decision_process",
"previous_supplier_contact", "services_needed",
"additional_info", "stakeholder_type",
"additional_info", "stakeholder_type", "verified_at",
):
assert expected in cols, f"Missing column: {expected}"