From aafb3cfc941df376bde67ada4a7e1ad563514012 Mon Sep 17 00:00:00 2001 From: Deeman Date: Mon, 23 Feb 2026 11:08:58 +0100 Subject: [PATCH] test: add e2e tests for all 9 email handlers (54 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock send_email and call each handler directly. Covers recipient, subject content, HTML design elements (wordmark, preheader, heat badges), from_addr, skip-on-missing-data guards, and email_sent_at timestamp updates. Also fixes IndexError in handle_send_welcome when payload has no name ("".split()[0] → safe fallback). Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/worker.py | 3 +- web/tests/test_emails.py | 593 ++++++++++++++++++++++++++++++++++ 2 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 web/tests/test_emails.py 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