feat(email-templates): tests, docs, and fix quote_verification sample data (subtask 8)
- Add 50 tests in test_email_templates.py:
- TestRenderEmailTemplate: all 11 registry templates render in EN + DE
without error; checks DOCTYPE, wordmark, font, CTA color, template-
specific content (heat badges, brief rows, weekly digest loop, etc.)
and registry structure
- TestEmailGalleryRoutes: access control, gallery list (all labels
present, preview links), preview pages (EN/DE/nonexistent/invalid-lang),
compose preview endpoint (plain + wrapped + empty body)
- Fix _quote_verification_sample: add missing recap_parts key — StrictUndefined
raised on the {% if recap_parts %} check when the variable was absent
- Update CHANGELOG.md: document email template system (renderer, base,
macros, 11 templates, registry, gallery, compose preview, removed helpers)
- Update PROJECT.md: add email template system + gallery to Done section
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -107,6 +107,7 @@ def _quote_verification_sample(lang: str) -> dict:
|
||||
"court_count": court_count,
|
||||
"facility_type": "Indoor Padel Club",
|
||||
"country": "Germany",
|
||||
"recap_parts": ["4 courts", "Indoor Padel Club", "Germany"],
|
||||
"preheader": t.get("email_quote_verify_preheader_courts", "").format(court_count=court_count),
|
||||
}
|
||||
|
||||
|
||||
248
web/tests/test_email_templates.py
Normal file
248
web/tests/test_email_templates.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Tests for the standalone email template renderer and admin gallery routes.
|
||||
|
||||
render_email_template() tests: each registry entry renders without error,
|
||||
produces a valid DOCTYPE document, includes the wordmark, and supports both
|
||||
EN and DE languages.
|
||||
|
||||
Admin gallery tests: access control, list page, preview page, error handling.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from padelnomics.core import utcnow_iso
|
||||
from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
|
||||
|
||||
|
||||
# ── render_email_template() ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRenderEmailTemplate:
|
||||
"""render_email_template() produces valid HTML for all registry entries."""
|
||||
|
||||
@pytest.mark.parametrize("slug", list(EMAIL_TEMPLATE_REGISTRY.keys()))
|
||||
def test_all_templates_render_en(self, slug):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY[slug]
|
||||
sample = entry["sample_data"]("en")
|
||||
html = render_email_template(entry["template"], lang="en", **sample)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "padelnomics" in html.lower()
|
||||
assert 'lang="en"' in html
|
||||
|
||||
@pytest.mark.parametrize("slug", list(EMAIL_TEMPLATE_REGISTRY.keys()))
|
||||
def test_all_templates_render_de(self, slug):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY[slug]
|
||||
sample = entry["sample_data"]("de")
|
||||
html = render_email_template(entry["template"], lang="de", **sample)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "padelnomics" in html.lower()
|
||||
assert 'lang="de"' in html
|
||||
|
||||
def test_magic_link_contains_verify_link(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "/auth/verify?token=" in html
|
||||
|
||||
def test_magic_link_has_preheader(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "display:none" in html # hidden preheader span
|
||||
|
||||
def test_lead_forward_has_heat_badge(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "HOT" in html
|
||||
assert "#DC2626" in html # HOT badge color
|
||||
|
||||
def test_lead_forward_has_brief_rows(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
# Brief rows table is rendered (e.g. "Facility" label)
|
||||
assert "Facility" in html
|
||||
|
||||
def test_lead_forward_has_contact_info(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "ceo@padelclub.es" in html
|
||||
assert "Carlos Rivera" in html
|
||||
|
||||
def test_weekly_digest_loops_over_leads(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["weekly_digest"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
# Sample data has 3 leads — all 3 countries should appear
|
||||
assert "Germany" in html
|
||||
assert "Austria" in html
|
||||
assert "Switzerland" in html
|
||||
|
||||
def test_weekly_digest_has_heat_badges(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["weekly_digest"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "HOT" in html
|
||||
assert "WARM" in html
|
||||
assert "COOL" in html
|
||||
|
||||
def test_welcome_has_quickstart_links(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["welcome"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "/planner" in html
|
||||
assert "/markets" in html
|
||||
|
||||
def test_admin_compose_renders_body_html(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["admin_compose"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "test message" in html.lower()
|
||||
|
||||
def test_business_plan_has_download_link(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["business_plan"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "/planner/export/" in html
|
||||
|
||||
def test_invalid_lang_raises(self):
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||
with pytest.raises(AssertionError, match="Unsupported lang"):
|
||||
render_email_template(entry["template"], lang="fr", **entry["sample_data"]("en"))
|
||||
|
||||
def test_non_emails_prefix_raises(self):
|
||||
with pytest.raises(AssertionError, match="Expected emails/ prefix"):
|
||||
render_email_template("base.html", lang="en")
|
||||
|
||||
def test_common_design_elements_present(self):
|
||||
"""Branded shell must include font + blue accent across all templates."""
|
||||
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||
assert "Bricolage Grotesque" in html
|
||||
assert "#1D4ED8" in html
|
||||
assert "padelnomics.io" in html
|
||||
|
||||
def test_registry_has_required_keys(self):
|
||||
for slug, entry in EMAIL_TEMPLATE_REGISTRY.items():
|
||||
assert "template" in entry, f"{slug}: missing 'template'"
|
||||
assert "label" in entry, f"{slug}: missing 'label'"
|
||||
assert "description" in entry, f"{slug}: missing 'description'"
|
||||
assert callable(entry.get("sample_data")), f"{slug}: sample_data must be callable"
|
||||
assert entry["template"].startswith("emails/"), f"{slug}: template must start with emails/"
|
||||
|
||||
|
||||
# ── Admin gallery routes ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(app, db):
|
||||
"""Test client with a user that has the admin role."""
|
||||
now = utcnow_iso()
|
||||
async with db.execute(
|
||||
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
|
||||
("gallery_admin@test.com", "Gallery Admin", now),
|
||||
) as cursor:
|
||||
admin_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async with app.test_client() as c:
|
||||
async with c.session_transaction() as sess:
|
||||
sess["user_id"] = admin_id
|
||||
yield c
|
||||
|
||||
|
||||
class TestEmailGalleryRoutes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_requires_auth(self, client):
|
||||
resp = await client.get("/admin/emails/gallery")
|
||||
assert resp.status_code == 302
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_list_returns_200(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_list_shows_all_template_labels(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery")
|
||||
html = (await resp.get_data(as_text=True))
|
||||
for entry in EMAIL_TEMPLATE_REGISTRY.values():
|
||||
assert entry["label"] in html, f"Expected label {entry['label']!r} on gallery page"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_magic_link_en(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery/magic_link")
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.get_data(as_text=True))
|
||||
assert "srcdoc" in html # sandboxed iframe is present
|
||||
assert "Magic Link" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_magic_link_de(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery/magic_link?lang=de")
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.get_data(as_text=True))
|
||||
assert 'lang="de"' in html or "de" in html # lang toggle shows active state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_lead_forward(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery/lead_forward")
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.get_data(as_text=True))
|
||||
assert "Lead Forward" in html
|
||||
assert "srcdoc" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_weekly_digest(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery/weekly_digest")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_nonexistent_slug_redirects(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery/does-not-exist")
|
||||
assert resp.status_code == 302
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_invalid_lang_falls_back(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery/magic_link?lang=fr")
|
||||
assert resp.status_code == 200 # invalid lang → falls back to "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_preview_requires_auth(self, client):
|
||||
resp = await client.get("/admin/emails/gallery/magic_link")
|
||||
assert resp.status_code == 302
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_list_has_preview_links(self, admin_client):
|
||||
resp = await admin_client.get("/admin/emails/gallery")
|
||||
html = (await resp.get_data(as_text=True))
|
||||
# Each card links to the preview page
|
||||
for slug in EMAIL_TEMPLATE_REGISTRY:
|
||||
assert f"/admin/emails/gallery/{slug}" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_preview_plain_body(self, admin_client):
|
||||
"""POST to compose/preview with wrap=0 returns plain HTML body."""
|
||||
resp = await admin_client.post(
|
||||
"/admin/emails/compose/preview",
|
||||
form={"body": "Hello world", "wrap": "0"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.get_data(as_text=True))
|
||||
assert "Hello world" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_preview_wrapped_body(self, admin_client):
|
||||
"""POST to compose/preview with wrap=1 wraps body in branded layout."""
|
||||
resp = await admin_client.post(
|
||||
"/admin/emails/compose/preview",
|
||||
form={"body": "Test preview content", "wrap": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.get_data(as_text=True))
|
||||
assert "Test preview content" in html
|
||||
# Branded wrapper includes padelnomics wordmark
|
||||
assert "padelnomics" in html.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_preview_empty_body(self, admin_client):
|
||||
"""Empty body returns an empty but valid partial."""
|
||||
resp = await admin_client.post(
|
||||
"/admin/emails/compose/preview",
|
||||
form={"body": "", "wrap": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
Reference in New Issue
Block a user