Compare commits

...

4 Commits

Author SHA1 Message Date
Deeman
70628ea881 merge(pipeline-transform-tab): split article editor + frontmatter fix + transform tab features
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
2026-03-02 11:34:13 +01:00
Deeman
d619f5e1ef feat(articles): split editor with live preview + fix frontmatter bug
Bug: article_edit GET was passing raw .md file content (including YAML
frontmatter) to the body textarea. Articles synced from disk via
_sync_static_articles() had their frontmatter bled into the editor,
making it look like content was missing or garbled.

Fix: strip frontmatter (using existing _FRONTMATTER_RE) before setting
body, consistent with how _rebuild_article() already does it.
Also switch to _ARTICLES_DIR (absolute) instead of relative path.

New: split editor layout — compact metadata strip at top, dark
monospace textarea on the left, live rendered preview on the right
(HTMX, 500ms debounce). Initial preview server-rendered on page load.
New POST /admin/articles/preview endpoint returns the preview partial.
2026-03-02 11:10:01 +01:00
Deeman
2a7eed1576 merge: test suite compression pass (-197 lines)
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 10:46:01 +01:00
Deeman
162e633c62 refactor(tests): compress admin_client + mock_send_email into conftest
Lift admin_client fixture from 7 duplicate definitions into conftest.py.
Add mock_send_email fixture, replacing 60 inline patch() blocks across
test_emails.py, test_waitlist.py, and test_businessplan.py. Net -197 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:40:52 +01:00
15 changed files with 728 additions and 561 deletions

View File

@@ -13,6 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **SQLMesh macros** (`transform/macros/__init__.py`): 5 new macros compress repeated country code patterns across 7 SQL models: `@country_name`, `@country_slug`, `@normalize_eurostat_country`, `@normalize_eurostat_nuts`, `@infer_country_from_coords`. - **SQLMesh macros** (`transform/macros/__init__.py`): 5 new macros compress repeated country code patterns across 7 SQL models: `@country_name`, `@country_slug`, `@normalize_eurostat_country`, `@normalize_eurostat_nuts`, `@infer_country_from_coords`.
- **Extract helpers** (`extract/utils.py`): `skip_if_current()` compresses cursor-check + early-return pattern (3 extractors); `write_jsonl_atomic()` compresses working-file → JSONL → compress pattern (2 extractors). - **Extract helpers** (`extract/utils.py`): `skip_if_current()` compresses cursor-check + early-return pattern (3 extractors); `write_jsonl_atomic()` compresses working-file → JSONL → compress pattern (2 extractors).
- **Coding philosophy updated** (`~/.claude/coding_philosophy.md`): added `<compression>` section documenting the workflow, the test ("Did this abstraction make the total codebase smaller?"), and distinction from premature DRY. - **Coding philosophy updated** (`~/.claude/coding_philosophy.md`): added `<compression>` section documenting the workflow, the test ("Did this abstraction make the total codebase smaller?"), and distinction from premature DRY.
- **Test suite compression pass** — applied same compression workflow to `web/tests/` (30 files, 13,949 lines). Net result: -197 lines across 11 files.
- **`admin_client` fixture** lifted from 7 duplicate definitions into `conftest.py`.
- **`mock_send_email` fixture** added to `conftest.py`, replacing 60 inline `with patch("padelnomics.worker.send_email", ...)` blocks across `test_emails.py` (51), `test_waitlist.py` (4), `test_businessplan.py` (2). Each refactored test drops one indentation level.
### Fixed ### Fixed
- **Admin: empty confirm dialog on auto-poll** — `htmx:confirm` handler now guards with `if (!evt.detail.question) return` so auto-poll requests (`hx-trigger="every 5s"`, no `hx-confirm` attribute) no longer trigger an empty dialog every 5 seconds. - **Admin: empty confirm dialog on auto-poll** — `htmx:confirm` handler now guards with `if (!evt.detail.question) return` so auto-poll requests (`hx-trigger="every 5s"`, no `hx-confirm` attribute) no longer trigger an empty dialog every 5 seconds.

View File

@@ -2474,7 +2474,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success") await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
return await render_template("admin/article_form.html", data={}, editing=False) return await render_template("admin/article_form.html", data={}, editing=False, body_html="")
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"]) @bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@@ -2544,18 +2544,41 @@ async def article_edit(article_id: int):
# Load markdown source if available (manual or generated) # Load markdown source if available (manual or generated)
from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR
md_path = Path("data/content/articles") / f"{article['slug']}.md" md_path = _ARTICLES_DIR / f"{article['slug']}.md"
if not md_path.exists(): if not md_path.exists():
lang = article["language"] or "en" lang = article["language"] or "en"
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md" md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
body = md_path.read_text() if md_path.exists() else "" raw = md_path.read_text() if md_path.exists() else ""
# Strip YAML frontmatter so only the prose body appears in the editor
m = _FRONTMATTER_RE.match(raw)
body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else ""
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
return await render_template( return await render_template(
"admin/article_form.html", data=data, editing=True, article_id=article_id, "admin/article_form.html",
data=data,
editing=True,
article_id=article_id,
body_html=body_html,
) )
@bp.route("/articles/preview", methods=["POST"])
@role_required("admin")
@csrf_protect
async def article_preview():
"""Render markdown body to HTML for the live editor preview panel."""
from markupsafe import Markup
form = await request.form
body = form.get("body", "")
m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body
body_html = Markup(mistune.html(body)) if body else ""
return await render_template("admin/partials/article_preview.html", body_html=body_html)
@bp.route("/articles/<int:article_id>/delete", methods=["POST"]) @bp.route("/articles/<int:article_id>/delete", methods=["POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect

View File

@@ -1,89 +1,422 @@
{% extends "admin/base_admin.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "articles" %} {% set admin_page = "articles" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Admin {{ config.APP_NAME }}{% endblock %}
{% block head %}{{ super() }}
<style>
/* Override admin-main so the split editor fills the column */
.admin-main {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Editor shell ──────────────────────────────────────────── */
.ae-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Toolbar ────────────────────────────────────────────────── */
.ae-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
background: #fff;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-toolbar__back {
font-size: 0.8125rem;
color: #64748B;
text-decoration: none;
flex-shrink: 0;
transition: color 0.1s;
}
.ae-toolbar__back:hover { color: #0F172A; }
.ae-toolbar__sep {
width: 1px; height: 1.25rem;
background: #E2E8F0;
flex-shrink: 0;
}
.ae-toolbar__title {
font-size: 0.875rem;
font-weight: 600;
color: #0F172A;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ae-toolbar__status {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
flex-shrink: 0;
}
.ae-toolbar__status--draft {
background: #F1F5F9;
color: #64748B;
}
.ae-toolbar__status--published {
background: #DCFCE7;
color: #16A34A;
}
/* ── Metadata strip ─────────────────────────────────────────── */
#ae-form {
display: contents; /* form participates in flex layout as transparent wrapper */
}
.ae-meta {
padding: 0.75rem 1.25rem;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-meta__row {
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
align-items: end;
}
.ae-meta__row + .ae-meta__row { margin-top: 0.5rem; }
.ae-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.ae-field--flex1 { flex: 1; min-width: 120px; }
.ae-field--flex2 { flex: 2; min-width: 180px; }
.ae-field--flex3 { flex: 3; min-width: 220px; }
.ae-field--fixed80 { flex: 0 0 80px; }
.ae-field--fixed120 { flex: 0 0 120px; }
.ae-field--fixed160 { flex: 0 0 160px; }
.ae-field label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94A3B8;
white-space: nowrap;
}
.ae-field input,
.ae-field select {
width: 100%;
padding: 0.3rem 0.5rem;
border: 1px solid #E2E8F0;
border-radius: 4px;
font-size: 0.8125rem;
font-family: var(--font-sans);
color: #0F172A;
background: #fff;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
min-width: 0;
}
.ae-field input:focus,
.ae-field select:focus {
border-color: #1D4ED8;
box-shadow: 0 0 0 2px rgba(29,78,216,0.1);
}
.ae-field input[readonly] {
background: #F1F5F9;
color: #94A3B8;
}
/* ── Split pane ─────────────────────────────────────────────── */
.ae-split {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
overflow: hidden;
}
.ae-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.ae-pane--editor { border-right: 1px solid #E2E8F0; }
.ae-pane__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.875rem;
background: #1E293B;
border-bottom: 1px solid #0F172A;
flex-shrink: 0;
}
.ae-pane--preview .ae-pane__header {
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
}
.ae-pane__label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: #94A3B8;
}
.ae-pane--editor .ae-pane__label { color: #475569; }
.ae-pane__hint {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #475569;
}
.ae-pane--preview .ae-pane__hint { color: #94A3B8; }
/* The markdown textarea */
.ae-editor {
flex: 1;
resize: none;
border: none;
outline: none;
padding: 1.125rem 1.25rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
line-height: 1.75;
background: #0F172A;
color: #CBD5E1;
caret-color: #3B82F6;
tab-size: 2;
}
.ae-editor::placeholder { color: #334155; }
.ae-editor:focus { outline: none; }
/* Preview pane */
.ae-preview {
flex: 1;
overflow-y: auto;
padding: 1.5rem 2rem;
background: #fff;
}
/* Preview typography — maps rendered markdown */
.ae-preview .preview-body { max-width: 42rem; }
.ae-preview .preview-body h1 {
font-family: var(--font-display);
font-size: 1.625rem; font-weight: 700;
color: #0F172A; margin: 0 0 1rem;
line-height: 1.25;
}
.ae-preview .preview-body h2 {
font-family: var(--font-display);
font-size: 1.25rem; font-weight: 600;
color: #0F172A; margin: 1.75rem 0 0.625rem;
}
.ae-preview .preview-body h3 {
font-size: 1.0625rem; font-weight: 600;
color: #0F172A; margin: 1.25rem 0 0.5rem;
}
.ae-preview .preview-body h4 {
font-size: 0.9375rem; font-weight: 600;
color: #334155; margin: 1rem 0 0.375rem;
}
.ae-preview .preview-body p { margin: 0 0 0.875rem; color: #1E293B; line-height: 1.75; }
.ae-preview .preview-body ul,
.ae-preview .preview-body ol { margin: 0 0 0.875rem 1.375rem; color: #1E293B; }
.ae-preview .preview-body li { margin: 0.3rem 0; line-height: 1.65; }
.ae-preview .preview-body code {
font-family: var(--font-mono); font-size: 0.8125rem;
background: #F1F5F9; color: #1D4ED8;
padding: 0.1rem 0.35rem; border-radius: 3px;
}
.ae-preview .preview-body pre {
background: #0F172A; color: #CBD5E1;
padding: 1rem 1.125rem; border-radius: 6px;
overflow-x: auto; margin: 0 0 0.875rem;
font-size: 0.8125rem; line-height: 1.65;
}
.ae-preview .preview-body pre code {
background: none; color: inherit; padding: 0;
}
.ae-preview .preview-body blockquote {
border-left: 3px solid #1D4ED8;
padding-left: 1rem; margin: 0 0 0.875rem;
color: #475569;
}
.ae-preview .preview-body a { color: #1D4ED8; }
.ae-preview .preview-body hr {
border: none; border-top: 1px solid #E2E8F0;
margin: 1.5rem 0;
}
.ae-preview .preview-body strong { font-weight: 600; color: #0F172A; }
.ae-preview .preview-body table {
width: 100%; border-collapse: collapse;
font-size: 0.875rem; margin: 0 0 0.875rem;
}
.ae-preview .preview-body th {
background: #F8FAFC; font-weight: 600;
padding: 0.5rem 0.75rem; text-align: left;
border: 1px solid #E2E8F0; color: #0F172A;
}
.ae-preview .preview-body td {
padding: 0.5rem 0.75rem;
border: 1px solid #E2E8F0; color: #1E293B;
}
.ae-preview .preview-body tr:nth-child(even) td { background: #F8FAFC; }
.preview-placeholder {
font-size: 0.875rem;
color: #94A3B8;
font-style: italic;
margin: 0;
}
/* HTMX loading indicator — htmx toggles .htmx-request on the element */
.ae-loading {
font-size: 0.625rem;
color: #94A3B8;
font-family: var(--font-mono);
opacity: 0;
transition: opacity 0.15s;
}
.ae-loading.htmx-request { opacity: 1; }
</style>
{% endblock %}
{% block admin_content %} {% block admin_content %}
<div style="max-width: 48rem; margin: 0 auto;"> <div class="ae-shell">
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">&larr; Back to articles</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
<form method="post" class="card"> <!-- Toolbar -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div class="ae-toolbar">
<a href="{{ url_for('admin.articles') }}" class="ae-toolbar__back">← Articles</a>
<div class="ae-toolbar__sep"></div>
<span class="ae-toolbar__title">
{% if editing %}{{ data.get('title', 'Edit Article') }}{% else %}New Article{% endif %}
</span>
{% if editing %}
<span class="ae-toolbar__status ae-toolbar__status--{{ data.get('status', 'draft') }}">
{{ data.get('status', 'draft') }}
</span>
{% endif %}
<button form="ae-form" type="submit" class="btn btn-sm">
{% if editing %}Save Changes{% else %}Create Article{% endif %}
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4"> <!-- Form wraps everything below the toolbar -->
<div> <form id="ae-form" method="post" style="display:contents;">
<label class="form-label" for="title">Title</label> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
<!-- Metadata strip -->
<div class="ae-meta">
<div class="ae-meta__row">
<div class="ae-field ae-field--flex3">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}"
required placeholder="Article title…">
</div> </div>
<div> <div class="ae-field ae-field--flex2">
<label class="form-label" for="slug">Slug</label> <label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input" <input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}"
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}> placeholder="auto-generated" {% if editing %}readonly{% endif %}>
</div> </div>
</div> <div class="ae-field ae-field--flex2">
<label for="url_path">URL Path</label>
<div class="mb-4"> <input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}"
<label class="form-label" for="url_path">URL Path</label> placeholder="/slug">
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
placeholder="e.g. /padel-court-cost-miami">
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
class="form-input" maxlength="160">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
placeholder="e.g. US">
</div> </div>
<div> <div class="ae-field ae-field--fixed80">
<label class="form-label" for="region">Region</label> <label for="language">Language</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input" <select id="language" name="language">
placeholder="e.g. North America"> <option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>EN</option>
</div> <option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
<div>
<label class="form-label" for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
</div>
</div>
<div class="mb-4">
<label class="form-label" for="body">Body (Markdown)</label>
<textarea id="body" name="body" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="language">Language</label>
<select id="language" name="language" class="form-input">
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>English (en)</option>
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>German (de)</option>
</select> </select>
</div> </div>
<div> <div class="ae-field ae-field--fixed120">
<label class="form-label" for="status">Status</label> <label for="status">Status</label>
<select id="status" name="status" class="form-input"> <select id="status" name="status">
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option> <option value="draft" {% if data.get('status', 'draft') == 'draft' %}selected{% endif %}>Draft</option>
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option> <option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
</select> </select>
</div> </div>
<div> </div>
<label class="form-label" for="published_at">Publish Date</label> <div class="ae-meta__row">
<div class="ae-field ae-field--flex3">
<label for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description"
value="{{ data.get('meta_description', '') }}" maxlength="160"
placeholder="160 chars max…">
</div>
<div class="ae-field ae-field--flex1">
<label for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}"
placeholder="e.g. US">
</div>
<div class="ae-field ae-field--flex1">
<label for="region">Region</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}"
placeholder="e.g. North America">
</div>
<div class="ae-field ae-field--flex2">
<label for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url"
value="{{ data.get('og_image_url', '') }}">
</div>
<div class="ae-field ae-field--fixed160">
<label for="published_at">Publish Date</label>
<input type="datetime-local" id="published_at" name="published_at" <input type="datetime-local" id="published_at" name="published_at"
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input"> value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}">
<p class="form-hint">Leave blank for now. Future date = scheduled.</p> </div>
</div>
</div>
<!-- Split: editor | preview -->
<div class="ae-split">
<!-- Left — Markdown editor -->
<div class="ae-pane ae-pane--editor">
<div class="ae-pane__header">
<span class="ae-pane__label">Markdown</span>
<span class="ae-pane__hint">[scenario:slug] · [product:slug]</span>
</div>
<textarea
id="body" name="body"
class="ae-editor"
{% if not editing %}required{% endif %}
placeholder="Start writing in Markdown…"
hx-post="{{ url_for('admin.article_preview') }}"
hx-trigger="input delay:500ms"
hx-target="#ae-preview-content"
hx-include="[name=csrf_token]"
hx-indicator="#ae-loading"
>{{ data.get('body', '') }}</textarea>
</div>
<!-- Right — Rendered preview -->
<div class="ae-pane ae-pane--preview">
<div class="ae-pane__header">
<span class="ae-pane__label">Preview</span>
<span id="ae-loading" class="ae-loading">Rendering…</span>
</div>
<div class="ae-preview">
<div id="ae-preview-content">
{% if body_html %}
<div class="preview-body">{{ body_html }}</div>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}
</div>
</div> </div>
</div> </div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button> </div>
</form> </form>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,5 @@
{% if body_html %}
<div class="preview-body">{{ body_html }}</div>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}

View File

@@ -125,6 +125,32 @@ async def auth_client(app, test_user):
yield c yield c
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin user pre-loaded in session."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (user_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
@pytest.fixture
def mock_send_email():
"""Patch padelnomics.worker.send_email for the duration of the test."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock:
yield mock
# ── Subscriptions ──────────────────────────────────────────── # ── Subscriptions ────────────────────────────────────────────
@pytest.fixture @pytest.fixture

View File

@@ -9,7 +9,6 @@ Covers:
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.businessplan import generate_business_plan, get_plan_sections from padelnomics.businessplan import generate_business_plan, get_plan_sections
@@ -184,19 +183,18 @@ async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pend
@requires_weasyprint @requires_weasyprint
class TestWorkerHandler: class TestWorkerHandler:
async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario): async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario, mock_send_email):
from padelnomics.worker import handle_generate_business_plan from padelnomics.worker import handle_generate_business_plan
export = await _insert_export(db, scenario["user_id"], scenario["id"]) export = await _insert_export(db, scenario["user_id"], scenario["id"])
output_file = None output_file = None
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email: await handle_generate_business_plan({
await handle_generate_business_plan({ "export_id": export["id"],
"export_id": export["id"], "user_id": scenario["user_id"],
"user_id": scenario["user_id"], "scenario_id": scenario["id"],
"scenario_id": scenario["id"], "language": "en",
"language": "en", })
})
# Status should be 'ready' # Status should be 'ready'
from padelnomics.core import fetch_one from padelnomics.core import fetch_one
@@ -214,14 +212,14 @@ class TestWorkerHandler:
assert output_file.read_bytes()[:4] == b"%PDF" assert output_file.read_bytes()[:4] == b"%PDF"
# Email should have been sent # Email should have been sent
mock_email.assert_called_once() mock_send_email.assert_called_once()
assert "to" in mock_email.call_args.kwargs assert "to" in mock_send_email.call_args.kwargs
assert "subject" in mock_email.call_args.kwargs assert "subject" in mock_send_email.call_args.kwargs
finally: finally:
if output_file and output_file.exists(): if output_file and output_file.exists():
output_file.unlink() output_file.unlink()
async def test_marks_failed_on_bad_scenario(self, db, scenario): async def test_marks_failed_on_bad_scenario(self, db, scenario, mock_send_email):
"""Handler marks export failed when user_id doesn't match scenario owner.""" """Handler marks export failed when user_id doesn't match scenario owner."""
from padelnomics.worker import handle_generate_business_plan from padelnomics.worker import handle_generate_business_plan
@@ -229,14 +227,13 @@ class TestWorkerHandler:
wrong_user_id = scenario["user_id"] + 9999 wrong_user_id = scenario["user_id"] + 9999
export = await _insert_export(db, scenario["user_id"], scenario["id"]) export = await _insert_export(db, scenario["user_id"], scenario["id"])
with patch("padelnomics.worker.send_email", new_callable=AsyncMock): with pytest.raises(ValueError):
with pytest.raises(ValueError): await handle_generate_business_plan({
await handle_generate_business_plan({ "export_id": export["id"],
"export_id": export["id"], "user_id": wrong_user_id,
"user_id": wrong_user_id, "scenario_id": scenario["id"],
"scenario_id": scenario["id"], "language": "en",
"language": "en", })
})
from padelnomics.core import fetch_one from padelnomics.core import fetch_one
row = await fetch_one( row = await fetch_one(

View File

@@ -938,26 +938,6 @@ class TestRouteRegistration:
# Admin routes (require admin session) # Admin routes (require admin session)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@pytest.fixture
async def admin_client(app, db):
"""Test client with admin user (has admin role)."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "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 TestAdminTemplates: class TestAdminTemplates:
async def test_template_list_requires_admin(self, client): async def test_template_list_requires_admin(self, client):
resp = await client.get("/admin/templates") resp = await client.get("/admin/templates")

View File

@@ -9,7 +9,6 @@ Admin gallery tests: access control, list page, preview page, error handling.
""" """
import pytest import pytest
from padelnomics.core import utcnow_iso
from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
# ── render_email_template() ────────────────────────────────────────────────── # ── render_email_template() ──────────────────────────────────────────────────
@@ -124,26 +123,6 @@ class TestRenderEmailTemplate:
# ── Admin gallery routes ────────────────────────────────────────────────────── # ── 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: class TestEmailGalleryRoutes:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_gallery_requires_auth(self, client): async def test_gallery_requires_auth(self, client):

View File

@@ -50,59 +50,51 @@ def _assert_common_design(html: str, lang: str = "en"):
class TestMagicLink: class TestMagicLink:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_correct_recipient(self): async def test_sends_to_correct_recipient(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert kw["to"] == "user@example.com"
assert kw["to"] == "user@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_app_name(self): async def test_subject_contains_app_name(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert core.config.APP_NAME.lower() in kw["subject"].lower()
assert core.config.APP_NAME.lower() in kw["subject"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_verify_link(self): async def test_html_contains_verify_link(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert "/auth/verify?token=abc123" in kw["html"]
assert "/auth/verify?token=abc123" in kw["html"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_fallback_link_text(self): async def test_html_contains_fallback_link_text(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "word-break:break-all" in html # fallback URL block
assert "word-break:break-all" in html # fallback URL block
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preheader_mentions_expiry(self): async def test_preheader_mentions_expiry(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # preheader is hidden span; should mention minutes
# preheader is hidden span; should mention minutes assert "display:none" in html # preheader present
assert "display:none" in html # preheader present
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_respects_lang_parameter(self): async def test_respects_lang_parameter(self, mock_send_email):
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"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] _assert_common_design(html, lang="de")
_assert_common_design(html, lang="de")
# ── Welcome ────────────────────────────────────────────────────── # ── Welcome ──────────────────────────────────────────────────────
@@ -110,59 +102,51 @@ class TestMagicLink:
class TestWelcome: class TestWelcome:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_correct_recipient(self): async def test_sends_to_correct_recipient(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) assert _call_kwargs(mock_send_email)["to"] == "new@example.com"
assert _call_kwargs(mock_send)["to"] == "new@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_not_empty(self): async def test_subject_not_empty(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) assert len(_call_kwargs(mock_send_email)["subject"]) > 5
assert len(_call_kwargs(mock_send)["subject"]) > 5
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_quickstart_links(self): async def test_html_contains_quickstart_links(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "/planner" in html
assert "/planner" in html assert "/markets" in html
assert "/markets" in html assert "/leads/quote" in html
assert "/leads/quote" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_first_name_when_provided(self): async def test_uses_first_name_when_provided(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "Alice" in html
assert "Alice" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fallback_greeting_when_no_name(self): async def test_fallback_greeting_when_no_name(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # Should use "there" as fallback first_name
# Should use "there" as fallback first_name assert "there" in html.lower()
assert "there" in html.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_german_welcome(self): async def test_german_welcome(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com", "lang": "de"})
await handle_send_welcome({"email": "new@example.com", "lang": "de"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] _assert_common_design(html, lang="de")
_assert_common_design(html, lang="de")
# ── Quote Verification ─────────────────────────────────────────── # ── Quote Verification ───────────────────────────────────────────
@@ -180,57 +164,50 @@ class TestQuoteVerification:
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_contact_email(self): async def test_sends_to_contact_email(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["to"] == "lead@example.com"
assert _call_kwargs(mock_send)["to"] == "lead@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_verify_link(self): async def test_html_contains_verify_link(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "token=verify_tok" in html
assert "token=verify_tok" in html assert "lead=lead_tok" in html
assert "lead=lead_tok" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_project_recap(self): async def test_html_contains_project_recap(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "6 courts" in html
assert "6 courts" in html assert "Indoor" in html
assert "Indoor" in html assert "Germany" in html
assert "Germany" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_first_name_from_contact(self): async def test_uses_first_name_from_contact(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "Bob" in html
assert "Bob" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handles_minimal_payload(self): async def test_handles_minimal_payload(self, mock_send_email):
"""No court_count/facility_type/country — should still send.""" """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({
await handle_send_quote_verification({ "email": "lead@example.com",
"email": "lead@example.com", "token": "tok",
"token": "tok", "lead_token": "ltok",
"lead_token": "ltok", })
}) mock_send_email.assert_called_once()
mock_send.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Lead Forward (the money email) ────────────────────────────── # ── Lead Forward (the money email) ──────────────────────────────
@@ -238,89 +215,71 @@ class TestQuoteVerification:
class TestLeadForward: class TestLeadForward:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_supplier_email(self, db): async def test_sends_to_supplier_email(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["to"] == "supplier@test.com"
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 @pytest.mark.asyncio
async def test_subject_contains_heat_and_country(self, db): async def test_subject_contains_heat_and_country(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: subject = _call_kwargs(mock_send_email)["subject"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "[HOT]" in subject
subject = _call_kwargs(mock_send)["subject"] assert "Germany" in subject
assert "[HOT]" in subject assert "4 courts" in subject
assert "Germany" in subject
assert "4 courts" in subject
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_heat_badge(self, db): async def test_html_contains_heat_badge(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "#DC2626" in html # HOT badge color
html = _call_kwargs(mock_send)["html"] assert "HOT" in html
assert "#DC2626" in html # HOT badge color
assert "HOT" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_project_brief(self, db): async def test_html_contains_project_brief(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "Indoor" in html
html = _call_kwargs(mock_send)["html"] assert "Germany" in html
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_contact_info(self, db): async def test_html_contains_contact_info(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "lead@buyer.com" in html
html = _call_kwargs(mock_send)["html"] assert "mailto:lead@buyer.com" in html
assert "lead@buyer.com" in html assert "John Doe" in html
assert "mailto:lead@buyer.com" in html
assert "John Doe" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_urgency_callout(self, db): async def test_html_contains_urgency_callout(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) # Urgency callout has yellow background
html = _call_kwargs(mock_send)["html"] assert "#FEF3C7" in html
# Urgency callout has yellow background
assert "#FEF3C7" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_direct_reply_cta(self, db): async def test_html_contains_direct_reply_cta(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) # Direct reply link text should mention the contact email
html = _call_kwargs(mock_send)["html"] assert "lead@buyer.com" in html
# Direct reply link text should mention the contact email
assert "lead@buyer.com" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db): async def test_uses_leads_from_addr(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
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 @pytest.mark.asyncio
async def test_updates_email_sent_at(self, db): async def test_updates_email_sent_at(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True) lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
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( async with db.execute(
"SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?", "SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
@@ -331,30 +290,24 @@ class TestLeadForward:
assert row["email_sent_at"] is not None assert row["email_sent_at"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self, db): async def test_skips_when_no_supplier_email(self, db, mock_send_email):
"""No email on supplier record — handler exits without sending.""" """No email on supplier record — handler exits without sending."""
lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="") lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="")
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: mock_send_email.assert_not_called()
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db): async def test_skips_when_lead_not_found(self, db, mock_send_email):
"""Non-existent lead_id — handler exits without sending.""" """Non-existent lead_id — handler exits without sending."""
_, supplier_id = await _seed_lead_and_supplier(db) _, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: mock_send_email.assert_not_called()
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self, db): async def test_design_elements_present(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: _assert_common_design(_call_kwargs(mock_send_email)["html"])
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 ──────────────────────────────────── # ── Lead Matched Notification ────────────────────────────────────
@@ -362,70 +315,55 @@ class TestLeadForward:
class TestLeadMatched: class TestLeadMatched:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_lead_contact_email(self, db): async def test_sends_to_lead_contact_email(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["to"] == "lead@buyer.com"
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["to"] == "lead@buyer.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_first_name(self, db): async def test_subject_contains_first_name(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert "John" in _call_kwargs(mock_send_email)["subject"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert "John" in _call_kwargs(mock_send)["subject"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_what_happens_next(self, db): async def test_html_contains_what_happens_next(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_matched_notification({"lead_id": lead_id}) # "What happens next" section and tip callout (blue bg)
html = _call_kwargs(mock_send)["html"] assert "#F0F9FF" in html # tip callout background
# "What happens next" section and tip callout (blue bg)
assert "#F0F9FF" in html # tip callout background
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_project_context(self, db): async def test_html_contains_project_context(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_matched_notification({"lead_id": lead_id}) assert "Indoor" in html
html = _call_kwargs(mock_send)["html"] assert "Germany" in html
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db): async def test_uses_leads_from_addr(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db): async def test_skips_when_lead_not_found(self, db, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_lead_matched_notification({"lead_id": 99999})
await handle_send_lead_matched_notification({"lead_id": 99999}) mock_send_email.assert_not_called()
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_no_contact_email(self, db): async def test_skips_when_no_contact_email(self, db, mock_send_email):
lead_id = await _seed_lead(db, contact_email="") lead_id = await _seed_lead(db, contact_email="")
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: mock_send_email.assert_not_called()
await handle_send_lead_matched_notification({"lead_id": lead_id})
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self, db): async def test_design_elements_present(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: _assert_common_design(_call_kwargs(mock_send_email)["html"])
await handle_send_lead_matched_notification({"lead_id": lead_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Supplier Enquiry ───────────────────────────────────────────── # ── Supplier Enquiry ─────────────────────────────────────────────
@@ -441,50 +379,43 @@ class TestSupplierEnquiry:
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_supplier_email(self): async def test_sends_to_supplier_email(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["to"] == "supplier@corp.com"
assert _call_kwargs(mock_send)["to"] == "supplier@corp.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_contact_name(self): async def test_subject_contains_contact_name(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) assert "Alice Smith" in _call_kwargs(mock_send_email)["subject"]
assert "Alice Smith" in _call_kwargs(mock_send)["subject"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_message(self): async def test_html_contains_message(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "4 courts" in html
assert "4 courts" in html assert "alice@buyer.com" in html
assert "alice@buyer.com" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_respond_fast_nudge(self): async def test_html_contains_respond_fast_nudge(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # The respond-fast nudge line should be present
# The respond-fast nudge line should be present assert "24" in html # "24 hours" reference
assert "24" in html # "24 hours" reference
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self): async def test_skips_when_no_supplier_email(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""}) mock_send_email.assert_not_called()
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Waitlist (supplement existing test_waitlist.py) ────────────── # ── Waitlist (supplement existing test_waitlist.py) ──────────────
@@ -494,33 +425,29 @@ class TestWaitlistEmails:
"""Verify design & content for waitlist confirmation emails.""" """Verify design & content for waitlist confirmation emails."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_general_waitlist_has_preheader(self): async def test_general_waitlist_has_preheader(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "display:none" in html # preheader span
assert "display:none" in html # preheader span
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supplier_waitlist_mentions_plan(self): async def test_supplier_waitlist_mentions_plan(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert "growth" in kw["subject"].lower()
assert "growth" in kw["subject"].lower() assert "supplier" in kw["html"].lower()
assert "supplier" in kw["html"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_general_waitlist_design_elements(self): async def test_general_waitlist_design_elements(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"}) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supplier_waitlist_perks_listed(self): async def test_supplier_waitlist_perks_listed(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # Should have <li> perks
# Should have <li> perks assert html.count("<li>") >= 3
assert html.count("<li>") >= 3
# ── DB seed helpers ────────────────────────────────────────────── # ── DB seed helpers ──────────────────────────────────────────────

View File

@@ -10,7 +10,6 @@ import sqlite3
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.core import utcnow_iso
from padelnomics.migrations.migrate import migrate from padelnomics.migrations.migrate import migrate
from padelnomics import core from padelnomics import core
@@ -25,25 +24,6 @@ def mock_csrf_validation():
yield yield
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin-role user session (module-level, follows test_content.py)."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("flags_admin@test.com", "Flags 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
async def _set_flag(db, name: str, enabled: bool, description: str = ""): async def _set_flag(db, name: str, enabled: bool, description: str = ""):
"""Insert or replace a flag in the test DB.""" """Insert or replace a flag in the test DB."""
await db.execute( await db.execute(

View File

@@ -46,26 +46,6 @@ def _bypass_csrf():
yield yield
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin user pre-loaded in session."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@example.com", "Admin User", now),
) as cursor:
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (user_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
async def _insert_supplier( async def _insert_supplier(
db, db,
name: str = "Test Supplier", name: str = "Test Supplier",

View File

@@ -14,31 +14,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import padelnomics.admin.pipeline_routes as pipeline_mod import padelnomics.admin.pipeline_routes as pipeline_mod
import pytest import pytest
from padelnomics.core import utcnow_iso
# ── Fixtures ────────────────────────────────────────────────────────────────── # ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin test client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("pipeline-admin@test.com", "Pipeline 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
@pytest.fixture @pytest.fixture
def state_db_dir(): def state_db_dir():
"""Temp directory with a seeded .state.sqlite for testing.""" """Temp directory with a seeded .state.sqlite for testing."""

View File

@@ -10,7 +10,6 @@ Covers:
import json import json
from unittest.mock import patch from unittest.mock import patch
import pytest
from padelnomics.content.health import ( from padelnomics.content.health import (
check_broken_scenario_refs, check_broken_scenario_refs,
check_hreflang_orphans, check_hreflang_orphans,
@@ -27,26 +26,6 @@ from padelnomics import core
# ── Fixtures ────────────────────────────────────────────────────────────────── # ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin test client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("pseo-admin@test.com", "pSEO 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
# ── DB helpers ──────────────────────────────────────────────────────────────── # ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -89,26 +89,6 @@ async def articles_data(db, seo_data):
await db.commit() await db.commit()
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "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
# ── Query function tests ───────────────────────────────────── # ── Query function tests ─────────────────────────────────────
class TestSearchPerformance: class TestSearchPerformance:

View File

@@ -188,59 +188,55 @@ class TestWorkerTask:
"""Test send_waitlist_confirmation worker task.""" """Test send_waitlist_confirmation worker task."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_entrepreneur_confirmation(self): async def test_sends_entrepreneur_confirmation(self, mock_send_email):
"""Task sends confirmation email for entrepreneur signup.""" """Task sends confirmation email for entrepreneur signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "entrepreneur@example.com",
"email": "entrepreneur@example.com", "intent": "signup",
"intent": "signup", })
})
mock_send.assert_called_once() mock_send_email.assert_called_once()
call_args = mock_send.call_args call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com" assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "notify you at launch" in call_args.kwargs["subject"].lower() assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower() assert "waitlist" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_supplier_confirmation(self): async def test_sends_supplier_confirmation(self, mock_send_email):
"""Task sends confirmation email for supplier signup.""" """Task sends confirmation email for supplier signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "supplier@example.com",
"email": "supplier@example.com", "intent": "supplier_growth",
"intent": "supplier_growth", })
})
mock_send.assert_called_once() mock_send_email.assert_called_once()
call_args = mock_send.call_args call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "supplier@example.com" assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower() assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower() assert "supplier" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supplier_email_includes_plan_name(self): async def test_supplier_email_includes_plan_name(self, mock_send_email):
"""Supplier confirmation should mention the specific plan.""" """Supplier confirmation should mention the specific plan."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "supplier@example.com",
"email": "supplier@example.com", "intent": "supplier_pro",
"intent": "supplier_pro", })
})
call_args = mock_send.call_args call_args = mock_send_email.call_args
html = call_args.kwargs["html"] html = call_args.kwargs["html"]
assert "pro" in html.lower() assert "pro" in html.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_email_address(self): async def test_uses_transactional_email_address(self, mock_send_email):
"""Task should use transactional sender address.""" """Task should use transactional sender address."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "test@example.com",
"email": "test@example.com", "intent": "signup",
"intent": "signup", })
})
call_args = mock_send.call_args call_args = mock_send_email.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"] assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
# ── TestAuthRoutes ──────────────────────────────────────────────── # ── TestAuthRoutes ────────────────────────────────────────────────