diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py
index f93bb28..315253d 100644
--- a/web/src/padelnomics/worker.py
+++ b/web/src/padelnomics/worker.py
@@ -298,7 +298,8 @@ async def handle_send_quote_verification(payload: dict) -> None:
async def handle_send_welcome(payload: dict) -> None:
"""Send welcome email to new user."""
lang = payload.get("lang", "en")
- first_name = (payload.get("name") or "").split()[0] or "there"
+ name_parts = (payload.get("name") or "").split()
+ first_name = name_parts[0] if name_parts else "there"
body = (
f'
{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}
'
diff --git a/web/tests/test_emails.py b/web/tests/test_emails.py
new file mode 100644
index 0000000..d1be6e8
--- /dev/null
+++ b/web/tests/test_emails.py
@@ -0,0 +1,593 @@
+"""
+Tests for all transactional email worker handlers.
+
+Each handler is tested by mocking send_email and calling the handler
+directly with a payload. Assertions cover: recipient, subject keywords,
+HTML content (design elements, i18n keys resolved), and from_addr.
+"""
+
+from datetime import UTC, datetime
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from padelnomics.worker import (
+ handle_send_lead_forward_email,
+ handle_send_lead_matched_notification,
+ handle_send_magic_link,
+ handle_send_quote_verification,
+ handle_send_supplier_enquiry_email,
+ handle_send_waitlist_confirmation,
+ handle_send_welcome,
+)
+
+from padelnomics import core
+
+# ── Helpers ──────────────────────────────────────────────────────
+
+
+def _call_kwargs(mock_send: AsyncMock) -> dict:
+ """Extract kwargs from first call to mock send_email."""
+ mock_send.assert_called_once()
+ return mock_send.call_args.kwargs
+
+
+def _assert_common_design(html: str, lang: str = "en"):
+ """Verify shared email wrapper design elements are present."""
+ assert "padelnomics" in html # wordmark
+ assert "Bricolage Grotesque" in html # brand font
+ assert "#1D4ED8" in html # blue accent / button
+ assert "padelnomics.io" in html # footer link
+ assert f'lang="{lang}"' in html # html lang attribute
+
+
+# ── Magic Link ───────────────────────────────────────────────────
+
+
+class TestMagicLink:
+ @pytest.mark.asyncio
+ async def test_sends_to_correct_recipient(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
+ kw = _call_kwargs(mock_send)
+ assert kw["to"] == "user@example.com"
+
+ @pytest.mark.asyncio
+ async def test_subject_contains_app_name(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
+ kw = _call_kwargs(mock_send)
+ assert core.config.APP_NAME.lower() in kw["subject"].lower()
+
+ @pytest.mark.asyncio
+ async def test_html_contains_verify_link(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
+ kw = _call_kwargs(mock_send)
+ assert "/auth/verify?token=abc123" in kw["html"]
+
+ @pytest.mark.asyncio
+ async def test_html_contains_fallback_link_text(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
+ html = _call_kwargs(mock_send)["html"]
+ assert "word-break:break-all" in html # fallback URL block
+
+ @pytest.mark.asyncio
+ async def test_uses_transactional_from_addr(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
+ assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
+
+ @pytest.mark.asyncio
+ async def test_preheader_mentions_expiry(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
+ html = _call_kwargs(mock_send)["html"]
+ # preheader is hidden span; should mention minutes
+ assert "display:none" in html # preheader present
+
+ @pytest.mark.asyncio
+ async def test_design_elements_present(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+ @pytest.mark.asyncio
+ async def test_respects_lang_parameter(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
+ html = _call_kwargs(mock_send)["html"]
+ _assert_common_design(html, lang="de")
+
+
+# ── Welcome ──────────────────────────────────────────────────────
+
+
+class TestWelcome:
+ @pytest.mark.asyncio
+ async def test_sends_to_correct_recipient(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com"})
+ assert _call_kwargs(mock_send)["to"] == "new@example.com"
+
+ @pytest.mark.asyncio
+ async def test_subject_not_empty(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com"})
+ assert len(_call_kwargs(mock_send)["subject"]) > 5
+
+ @pytest.mark.asyncio
+ async def test_html_contains_quickstart_links(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com"})
+ html = _call_kwargs(mock_send)["html"]
+ assert "/planner" in html
+ assert "/markets" in html
+ assert "/leads/quote" in html
+
+ @pytest.mark.asyncio
+ async def test_uses_first_name_when_provided(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
+ html = _call_kwargs(mock_send)["html"]
+ assert "Alice" in html
+
+ @pytest.mark.asyncio
+ async def test_fallback_greeting_when_no_name(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com"})
+ html = _call_kwargs(mock_send)["html"]
+ # Should use "there" as fallback first_name
+ assert "there" in html.lower()
+
+ @pytest.mark.asyncio
+ async def test_uses_transactional_from_addr(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com"})
+ assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
+
+ @pytest.mark.asyncio
+ async def test_design_elements_present(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com"})
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+ @pytest.mark.asyncio
+ async def test_german_welcome(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_welcome({"email": "new@example.com", "lang": "de"})
+ html = _call_kwargs(mock_send)["html"]
+ _assert_common_design(html, lang="de")
+
+
+# ── Quote Verification ───────────────────────────────────────────
+
+
+class TestQuoteVerification:
+ _BASE_PAYLOAD = {
+ "email": "lead@example.com",
+ "token": "verify_tok",
+ "lead_token": "lead_tok",
+ "contact_name": "Bob Builder",
+ "court_count": 6,
+ "facility_type": "Indoor",
+ "country": "Germany",
+ }
+
+ @pytest.mark.asyncio
+ async def test_sends_to_contact_email(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification(self._BASE_PAYLOAD)
+ assert _call_kwargs(mock_send)["to"] == "lead@example.com"
+
+ @pytest.mark.asyncio
+ async def test_html_contains_verify_link(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification(self._BASE_PAYLOAD)
+ html = _call_kwargs(mock_send)["html"]
+ assert "token=verify_tok" in html
+ assert "lead=lead_tok" in html
+
+ @pytest.mark.asyncio
+ async def test_html_contains_project_recap(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification(self._BASE_PAYLOAD)
+ html = _call_kwargs(mock_send)["html"]
+ assert "6 courts" in html
+ assert "Indoor" in html
+ assert "Germany" in html
+
+ @pytest.mark.asyncio
+ async def test_uses_first_name_from_contact(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification(self._BASE_PAYLOAD)
+ html = _call_kwargs(mock_send)["html"]
+ assert "Bob" in html
+
+ @pytest.mark.asyncio
+ async def test_handles_minimal_payload(self):
+ """No court_count/facility_type/country — should still send."""
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification({
+ "email": "lead@example.com",
+ "token": "tok",
+ "lead_token": "ltok",
+ })
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_uses_transactional_from_addr(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification(self._BASE_PAYLOAD)
+ assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
+
+ @pytest.mark.asyncio
+ async def test_design_elements_present(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_quote_verification(self._BASE_PAYLOAD)
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+
+# ── Lead Forward (the money email) ──────────────────────────────
+
+
+class TestLeadForward:
+ @pytest.mark.asyncio
+ async def test_sends_to_supplier_email(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ assert _call_kwargs(mock_send)["to"] == "supplier@test.com"
+
+ @pytest.mark.asyncio
+ async def test_subject_contains_heat_and_country(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ subject = _call_kwargs(mock_send)["subject"]
+ assert "[HOT]" in subject
+ assert "Germany" in subject
+ assert "4 courts" in subject
+
+ @pytest.mark.asyncio
+ async def test_html_contains_heat_badge(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ html = _call_kwargs(mock_send)["html"]
+ assert "#DC2626" in html # HOT badge color
+ assert "HOT" in html
+
+ @pytest.mark.asyncio
+ async def test_html_contains_project_brief(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ html = _call_kwargs(mock_send)["html"]
+ assert "Indoor" in html
+ assert "Germany" in html
+
+ @pytest.mark.asyncio
+ async def test_html_contains_contact_info(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ html = _call_kwargs(mock_send)["html"]
+ assert "lead@buyer.com" in html
+ assert "mailto:lead@buyer.com" in html
+ assert "John Doe" in html
+
+ @pytest.mark.asyncio
+ async def test_html_contains_urgency_callout(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ html = _call_kwargs(mock_send)["html"]
+ # Urgency callout has yellow background
+ assert "#FEF3C7" in html
+
+ @pytest.mark.asyncio
+ async def test_html_contains_direct_reply_cta(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ html = _call_kwargs(mock_send)["html"]
+ # Direct reply link text should mention the contact email
+ assert "lead@buyer.com" in html
+
+ @pytest.mark.asyncio
+ async def test_uses_leads_from_addr(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
+
+ @pytest.mark.asyncio
+ async def test_updates_email_sent_at(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+
+ async with db.execute(
+ "SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
+ (lead_id, supplier_id),
+ ) as cursor:
+ row = await cursor.fetchone()
+ assert row is not None
+ assert row["email_sent_at"] is not None
+
+ @pytest.mark.asyncio
+ async def test_skips_when_no_supplier_email(self, db):
+ """No email on supplier record — handler exits without sending."""
+ lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="")
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_skips_when_lead_not_found(self, db):
+ """Non-existent lead_id — handler exits without sending."""
+ _, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_design_elements_present(self, db):
+ lead_id, supplier_id = await _seed_lead_and_supplier(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+
+# ── Lead Matched Notification ────────────────────────────────────
+
+
+class TestLeadMatched:
+ @pytest.mark.asyncio
+ async def test_sends_to_lead_contact_email(self, db):
+ lead_id = await _seed_lead(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ assert _call_kwargs(mock_send)["to"] == "lead@buyer.com"
+
+ @pytest.mark.asyncio
+ async def test_subject_contains_first_name(self, db):
+ lead_id = await _seed_lead(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ assert "John" in _call_kwargs(mock_send)["subject"]
+
+ @pytest.mark.asyncio
+ async def test_html_contains_what_happens_next(self, db):
+ lead_id = await _seed_lead(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ html = _call_kwargs(mock_send)["html"]
+ # "What happens next" section and tip callout (blue bg)
+ assert "#F0F9FF" in html # tip callout background
+
+ @pytest.mark.asyncio
+ async def test_html_contains_project_context(self, db):
+ lead_id = await _seed_lead(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ html = _call_kwargs(mock_send)["html"]
+ assert "Indoor" in html
+ assert "Germany" in html
+
+ @pytest.mark.asyncio
+ async def test_uses_leads_from_addr(self, db):
+ lead_id = await _seed_lead(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
+
+ @pytest.mark.asyncio
+ async def test_skips_when_lead_not_found(self, db):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": 99999})
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_skips_when_no_contact_email(self, db):
+ lead_id = await _seed_lead(db, contact_email="")
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_design_elements_present(self, db):
+ lead_id = await _seed_lead(db)
+
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_lead_matched_notification({"lead_id": lead_id})
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+
+# ── Supplier Enquiry ─────────────────────────────────────────────
+
+
+class TestSupplierEnquiry:
+ _BASE_PAYLOAD = {
+ "supplier_email": "supplier@corp.com",
+ "supplier_name": "PadelBuild GmbH",
+ "contact_name": "Alice Smith",
+ "contact_email": "alice@buyer.com",
+ "message": "I need 4 courts, can you quote?",
+ }
+
+ @pytest.mark.asyncio
+ async def test_sends_to_supplier_email(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
+ assert _call_kwargs(mock_send)["to"] == "supplier@corp.com"
+
+ @pytest.mark.asyncio
+ async def test_subject_contains_contact_name(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
+ assert "Alice Smith" in _call_kwargs(mock_send)["subject"]
+
+ @pytest.mark.asyncio
+ async def test_html_contains_message(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
+ html = _call_kwargs(mock_send)["html"]
+ assert "4 courts" in html
+ assert "alice@buyer.com" in html
+
+ @pytest.mark.asyncio
+ async def test_html_contains_respond_fast_nudge(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
+ html = _call_kwargs(mock_send)["html"]
+ # The respond-fast nudge line should be present
+ assert "24" in html # "24 hours" reference
+
+ @pytest.mark.asyncio
+ async def test_skips_when_no_supplier_email(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_uses_transactional_from_addr(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
+ assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
+
+ @pytest.mark.asyncio
+ async def test_design_elements_present(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+
+# ── Waitlist (supplement existing test_waitlist.py) ──────────────
+
+
+class TestWaitlistEmails:
+ """Verify design & content for waitlist confirmation emails."""
+
+ @pytest.mark.asyncio
+ async def test_general_waitlist_has_preheader(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
+ html = _call_kwargs(mock_send)["html"]
+ assert "display:none" in html # preheader span
+
+ @pytest.mark.asyncio
+ async def test_supplier_waitlist_mentions_plan(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
+ kw = _call_kwargs(mock_send)
+ assert "growth" in kw["subject"].lower()
+ assert "supplier" in kw["html"].lower()
+
+ @pytest.mark.asyncio
+ async def test_general_waitlist_design_elements(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
+ _assert_common_design(_call_kwargs(mock_send)["html"])
+
+ @pytest.mark.asyncio
+ async def test_supplier_waitlist_perks_listed(self):
+ with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
+ await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
+ html = _call_kwargs(mock_send)["html"]
+ # Should have perks
+ assert html.count("") >= 3
+
+
+# ── DB seed helpers ──────────────────────────────────────────────
+
+
+async def _seed_lead(
+ db,
+ contact_email: str = "lead@buyer.com",
+ contact_name: str = "John Doe",
+) -> int:
+ """Insert a lead_request row, return its id."""
+ now = datetime.now(UTC).isoformat()
+ async with db.execute(
+ """INSERT INTO lead_requests (
+ lead_type, contact_name, contact_email, contact_phone,
+ contact_company, stakeholder_type, facility_type, court_count,
+ country, location, build_context, glass_type, lighting_type,
+ timeline, budget_estimate, location_status, financing_status,
+ services_needed, additional_info, heat_score,
+ status, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ "quote", contact_name, contact_email, "+49123456",
+ "Padel Corp", "investor", "Indoor", 4,
+ "Germany", "Berlin", "new_build", "tempered", "LED",
+ "6-12 months", 200000, "secured", "seeking",
+ "construction, lighting", "Need turnkey solution", "hot",
+ "verified", now,
+ ),
+ ) as cursor:
+ lead_id = cursor.lastrowid
+ await db.commit()
+ return lead_id
+
+
+async def _seed_supplier(
+ db,
+ contact_email: str = "supplier@test.com",
+) -> int:
+ """Insert a supplier row, return its id."""
+ now = datetime.now(UTC).isoformat()
+ async with db.execute(
+ """INSERT INTO suppliers (
+ name, slug, country_code, region, category,
+ contact_email, tier, claimed_by, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ "Test Supplier", "test-supplier", "DE", "Europe", "construction",
+ contact_email, "growth", None, now,
+ ),
+ ) as cursor:
+ supplier_id = cursor.lastrowid
+ await db.commit()
+ return supplier_id
+
+
+async def _seed_lead_and_supplier(
+ db,
+ supplier_email: str = "supplier@test.com",
+ create_forward: bool = False,
+) -> tuple[int, int]:
+ """Insert a lead + supplier pair, optionally with a lead_forwards row."""
+ lead_id = await _seed_lead(db)
+ supplier_id = await _seed_supplier(db, contact_email=supplier_email)
+
+ if create_forward:
+ now = datetime.now(UTC).isoformat()
+ await db.execute(
+ """INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
+ VALUES (?, ?, ?, ?)""",
+ (lead_id, supplier_id, 1, now),
+ )
+ await db.commit()
+
+ return lead_id, supplier_id