Add full email management at /admin/emails with: - email_log table tracking all outgoing emails with resend_id + delivery events - inbound_emails table for Resend webhook-received messages - Resend webhook handler (/webhooks/resend) updating delivery status in real-time - send_email() returns resend_id (str|None) instead of bool; all 9 worker handlers pass email_type= for per-type filtering - Admin UI: sent log with HTMX filters, email detail with API-enriched HTML preview, inbox with unread badges + reply, compose with branded wrapping, audience management with contact list/remove - Sidebar Email section with unread badge via blueprint context processor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
843 lines
35 KiB
Python
843 lines
35 KiB
Python
"""
|
|
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.
|
|
|
|
The TestResendLive class at the bottom sends real emails via Resend's
|
|
@resend.dev test addresses. Skipped unless RESEND_API_KEY is set.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
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 <li> perks
|
|
assert html.count("<li>") >= 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
|
|
|
|
|
|
# ── Live Resend integration tests ────────────────────────────────
|
|
#
|
|
# These send real emails to Resend's @resend.dev test addresses,
|
|
# then retrieve the sent email via resend.Emails.get() to verify the
|
|
# rendered HTML contains expected design elements and content.
|
|
#
|
|
# Skipped unless RESEND_API_KEY is set in the environment.
|
|
# Run with: RESEND_API_KEY=re_xxx uv run pytest web/tests/test_emails.py -k resend_live -v
|
|
|
|
_has_resend_key = bool(os.environ.get("RESEND_API_KEY"))
|
|
_skip_no_key = pytest.mark.skipif(not _has_resend_key, reason="RESEND_API_KEY not set")
|
|
|
|
|
|
def _send_and_capture():
|
|
"""Patch resend.Emails.send to capture the returned email ID.
|
|
|
|
Returns (wrapper, captured) where captured is a dict that will
|
|
contain {"id": ...} after send is called. The real send still
|
|
executes — this just grabs the return value that core.send_email
|
|
discards.
|
|
"""
|
|
import resend as _resend
|
|
|
|
_original_send = _resend.Emails.send
|
|
captured = {}
|
|
|
|
def _wrapper(params):
|
|
result = _original_send(params)
|
|
captured["id"] = result.id if hasattr(result, "id") else result.get("id")
|
|
return result
|
|
|
|
return _wrapper, captured
|
|
|
|
|
|
def _retrieve_html(captured: dict) -> str | None:
|
|
"""Retrieve the sent email from Resend and return its HTML.
|
|
|
|
Returns None if:
|
|
- The send failed (rate limit, network error) — captured has no id
|
|
- The API key lacks read permission (sending_access only)
|
|
"""
|
|
import resend as _resend
|
|
|
|
email_id = captured.get("id")
|
|
if not email_id:
|
|
return None # send failed (e.g. rate limit) — can't retrieve
|
|
try:
|
|
email = _resend.Emails.get(email_id)
|
|
except Exception:
|
|
# sending_access key — can't retrieve, that's OK
|
|
return None
|
|
return email.html if hasattr(email, "html") else email.get("html", "")
|
|
|
|
|
|
@_skip_no_key
|
|
class TestResendLive:
|
|
"""Send real emails via Resend to @resend.dev test addresses.
|
|
|
|
Uses ``delivered@resend.dev`` which simulates successful delivery.
|
|
After sending, retrieves the email via resend.Emails.get() and
|
|
asserts on the rendered HTML (design elements, content, structure).
|
|
|
|
Each test calls the handler WITHOUT mocking send_email, so the full
|
|
path is exercised: handler → _email_wrap → send_email → Resend API.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def _set_resend_key(self):
|
|
"""Ensure config picks up the real API key from env.
|
|
|
|
Adds a 0.6s pause between tests to respect Resend's 2 req/sec
|
|
rate limit.
|
|
"""
|
|
import resend as _resend
|
|
|
|
original = core.config.RESEND_API_KEY
|
|
core.config.RESEND_API_KEY = os.environ["RESEND_API_KEY"]
|
|
_resend.api_key = os.environ["RESEND_API_KEY"]
|
|
yield
|
|
core.config.RESEND_API_KEY = original
|
|
await asyncio.sleep(0.6)
|
|
|
|
def _check_html(self, captured: dict, assertions: list[tuple[str, str]]):
|
|
"""Retrieve sent email and run assertions on HTML.
|
|
|
|
If the API key lacks read permission (sending_access only),
|
|
the retrieve is skipped — the test still passes because the
|
|
send itself succeeded (no exception from handler).
|
|
|
|
assertions: list of (needle, description) tuples.
|
|
"""
|
|
html = _retrieve_html(captured)
|
|
if html is None:
|
|
return # sending_access key — can't retrieve, send was enough
|
|
for needle, desc in assertions:
|
|
assert needle in html, f"Expected {desc!r} ({needle!r}) in rendered HTML"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_magic_link_delivered(self):
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_magic_link({
|
|
"email": "delivered@resend.dev",
|
|
"token": "test_token_magic",
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("padelnomics", "wordmark"),
|
|
("Bricolage Grotesque", "brand font"),
|
|
("/auth/verify?token=test_token_magic", "verify link"),
|
|
("padelnomics.io", "footer link"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_welcome_delivered(self):
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_welcome({
|
|
"email": "delivered@resend.dev",
|
|
"name": "Test User",
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("Test", "first name"),
|
|
("/planner", "planner link"),
|
|
("/markets", "markets link"),
|
|
("/leads/quote", "quotes link"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quote_verification_delivered(self):
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_quote_verification({
|
|
"email": "delivered@resend.dev",
|
|
"token": "test_verify_tok",
|
|
"lead_token": "test_lead_tok",
|
|
"contact_name": "Test Buyer",
|
|
"court_count": 4,
|
|
"facility_type": "Indoor",
|
|
"country": "Germany",
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("4 courts", "court count"),
|
|
("Indoor", "facility type"),
|
|
("Germany", "country"),
|
|
("token=test_verify_tok", "verify token"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_waitlist_general_delivered(self):
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_waitlist_confirmation({
|
|
"email": "delivered@resend.dev",
|
|
"intent": "signup",
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("padelnomics", "wordmark"),
|
|
("<li>", "perk bullets"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_waitlist_supplier_delivered(self):
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_waitlist_confirmation({
|
|
"email": "delivered@resend.dev",
|
|
"intent": "supplier_growth",
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("Growth", "plan name"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lead_forward_delivered(self, db):
|
|
lead_id, supplier_id = await _seed_lead_and_supplier(
|
|
db, supplier_email="delivered@resend.dev",
|
|
)
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_lead_forward_email({
|
|
"lead_id": lead_id,
|
|
"supplier_id": supplier_id,
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("#DC2626", "HOT badge color"),
|
|
("lead@buyer.com", "contact email"),
|
|
("mailto:lead@buyer.com", "mailto link"),
|
|
("#FEF3C7", "urgency callout bg"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lead_matched_delivered(self, db):
|
|
lead_id = await _seed_lead(db, contact_email="delivered@resend.dev")
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_lead_matched_notification({
|
|
"lead_id": lead_id,
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("#F0F9FF", "tip callout bg"),
|
|
("Indoor", "facility type"),
|
|
("Germany", "country"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supplier_enquiry_delivered(self):
|
|
wrapper, captured = _send_and_capture()
|
|
with patch("resend.Emails.send", side_effect=wrapper):
|
|
await handle_send_supplier_enquiry_email({
|
|
"supplier_email": "delivered@resend.dev",
|
|
"supplier_name": "PadelBuild GmbH",
|
|
"contact_name": "Alice Smith",
|
|
"contact_email": "alice@buyer.example",
|
|
"message": "I need 4 courts, can you quote?",
|
|
"lang": "en",
|
|
})
|
|
self._check_html(captured, [
|
|
("Alice Smith", "contact name"),
|
|
("alice@buyer.example", "contact email"),
|
|
("4 courts", "message content"),
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bounce_handled_gracefully(self):
|
|
"""Sending to bounced@resend.dev should not raise — send_email returns str|None."""
|
|
result = await core.send_email(
|
|
to="bounced@resend.dev",
|
|
subject="Bounce test",
|
|
html="<p>This should bounce.</p>",
|
|
from_addr=core.EMAIL_ADDRESSES["transactional"],
|
|
)
|
|
# Resend may return success (delivery fails async) or error;
|
|
# either way the handler must not crash.
|
|
# send_email now returns resend_id (str) on success, None on failure.
|
|
assert result is None or isinstance(result, str)
|