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:
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user