Compare commits

..

6 Commits

Author SHA1 Message Date
Deeman
94d92328b8 merge: fix article .md lookup + lighter editor
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 11:47:13 +01:00
Deeman
100e200c3b fix(articles): find .md by slug scan + lighter editor theme
Two fixes:
- _find_article_md() scans _ARTICLES_DIR for files whose frontmatter
  slug matches, so padel-halle-bauen-de.md is found for slug
  'padel-halle-bauen'. The previous exact-name lookup missed any file
  where the filename ≠ slug (e.g. {slug}-{lang}.md naming convention).
- Editor pane: replace dark navy background with warm off-white (#FEFDFB)
  and dark text so it reads like a document, not a code editor.
2026-03-02 11:43:26 +01:00
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 756 additions and 563 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`.
- **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.
- **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
- **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

@@ -2121,6 +2121,27 @@ _ARTICLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "co
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _find_article_md(slug: str) -> Path | None:
"""Return the Path of the .md file whose frontmatter slug matches, or None.
Tries the exact name first ({slug}.md), then scans _ARTICLES_DIR for any
file whose YAML frontmatter contains 'slug: <slug>'. This handles the
common pattern where files are named {slug}-{lang}.md but the frontmatter
slug omits the language suffix.
"""
if not _ARTICLES_DIR.is_dir():
return None
exact = _ARTICLES_DIR / f"{slug}.md"
if exact.exists():
return exact
for path in _ARTICLES_DIR.glob("*.md"):
raw = path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(raw)
if m and f"slug: {slug}" in m.group(1):
return path
return None
async def _sync_static_articles() -> None:
"""Upsert static .md articles from data/content/articles/ into the DB.
@@ -2474,7 +2495,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success")
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"])
@@ -2544,18 +2565,42 @@ async def article_edit(article_id: int):
# Load markdown source if available (manual or generated)
from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
md_path = _find_article_md(article["slug"])
if md_path is None:
lang = article["language"] or "en"
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
body = md_path.read_text() if md_path.exists() else ""
fallback = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
md_path = fallback if fallback.exists() else None
raw = md_path.read_text() if md_path 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}
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"])
@role_required("admin")
@csrf_protect

View File

@@ -1,89 +1,426 @@
{% extends "admin/base_admin.html" %}
{% 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: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
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__hint {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
}
/* The markdown textarea */
.ae-editor {
flex: 1;
resize: none;
border: none;
outline: none;
padding: 1.5rem 2rem;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.8;
background: #FEFDFB;
color: #1E293B;
caret-color: #1D4ED8;
tab-size: 2;
}
.ae-editor::placeholder { color: #CBD5E1; }
.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.2s;
}
.ae-loading.htmx-request { opacity: 1; }
/* Responsive: stack on narrow screens */
@media (max-width: 900px) {
.ae-split { grid-template-columns: 1fr; }
.ae-pane--preview { display: none; }
}
</style>
{% endblock %}
{% block admin_content %}
<div style="max-width: 48rem; margin: 0 auto;">
<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>
<div class="ae-shell">
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Toolbar -->
<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">
<div>
<label class="form-label" for="title">Title</label>
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
<!-- Form wraps everything below the toolbar -->
<form id="ae-form" method="post" style="display:contents;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- 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>
<label class="form-label" for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}>
<div class="ae-field ae-field--flex2">
<label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}"
placeholder="auto-generated" {% if editing %}readonly{% endif %}>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="url_path">URL Path</label>
<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 class="ae-field ae-field--flex2">
<label for="url_path">URL Path</label>
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}"
placeholder="/slug">
</div>
<div>
<label class="form-label" for="region">Region</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input"
placeholder="e.g. North America">
</div>
<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>
<div class="ae-field ae-field--fixed80">
<label for="language">Language</label>
<select id="language" name="language">
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>EN</option>
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
</select>
</div>
<div>
<label class="form-label" for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option>
<div class="ae-field ae-field--fixed120">
<label for="status">Status</label>
<select id="status" name="status">
<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>
</select>
</div>
<div>
<label class="form-label" for="published_at">Publish Date</label>
</div>
<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"
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input">
<p class="form-hint">Leave blank for now. Future date = scheduled.</p>
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}">
</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>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
</form>
</div>
</div>
</form>
</div>
{% 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
@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 ────────────────────────────────────────────
@pytest.fixture

View File

@@ -9,7 +9,6 @@ Covers:
"""
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
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
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
export = await _insert_export(db, scenario["user_id"], scenario["id"])
output_file = None
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email:
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": scenario["user_id"],
"scenario_id": scenario["id"],
"language": "en",
})
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": scenario["user_id"],
"scenario_id": scenario["id"],
"language": "en",
})
# Status should be 'ready'
from padelnomics.core import fetch_one
@@ -214,14 +212,14 @@ class TestWorkerHandler:
assert output_file.read_bytes()[:4] == b"%PDF"
# Email should have been sent
mock_email.assert_called_once()
assert "to" in mock_email.call_args.kwargs
assert "subject" in mock_email.call_args.kwargs
mock_send_email.assert_called_once()
assert "to" in mock_send_email.call_args.kwargs
assert "subject" in mock_send_email.call_args.kwargs
finally:
if output_file and output_file.exists():
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."""
from padelnomics.worker import handle_generate_business_plan
@@ -229,14 +227,13 @@ class TestWorkerHandler:
wrong_user_id = scenario["user_id"] + 9999
export = await _insert_export(db, scenario["user_id"], scenario["id"])
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
with pytest.raises(ValueError):
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": wrong_user_id,
"scenario_id": scenario["id"],
"language": "en",
})
with pytest.raises(ValueError):
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": wrong_user_id,
"scenario_id": scenario["id"],
"language": "en",
})
from padelnomics.core import fetch_one
row = await fetch_one(

View File

@@ -938,26 +938,6 @@ class TestRouteRegistration:
# 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:
async def test_template_list_requires_admin(self, client):
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
from padelnomics.core import utcnow_iso
from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
# ── render_email_template() ──────────────────────────────────────────────────
@@ -124,26 +123,6 @@ class TestRenderEmailTemplate:
# ── 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):

View File

@@ -50,59 +50,51 @@ def _assert_common_design(html: str, lang: str = "en"):
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"
async def test_sends_to_correct_recipient(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send_email)
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()
async def test_subject_contains_app_name(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send_email)
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"]
async def test_html_contains_verify_link(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send_email)
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
async def test_html_contains_fallback_link_text(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send_email)["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"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
assert _call_kwargs(mock_send_email)["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
async def test_preheader_mentions_expiry(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send_email)["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"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
_assert_common_design(_call_kwargs(mock_send_email)["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")
async def test_respects_lang_parameter(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
html = _call_kwargs(mock_send_email)["html"]
_assert_common_design(html, lang="de")
# ── Welcome ──────────────────────────────────────────────────────
@@ -110,59 +102,51 @@ class TestMagicLink:
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"
async def test_sends_to_correct_recipient(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send_email)["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
async def test_subject_not_empty(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
assert len(_call_kwargs(mock_send_email)["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
async def test_html_contains_quickstart_links(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send_email)["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
async def test_uses_first_name_when_provided(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
html = _call_kwargs(mock_send_email)["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()
async def test_fallback_greeting_when_no_name(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send_email)["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"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send_email)["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"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
_assert_common_design(_call_kwargs(mock_send_email)["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")
async def test_german_welcome(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com", "lang": "de"})
html = _call_kwargs(mock_send_email)["html"]
_assert_common_design(html, lang="de")
# ── Quote Verification ───────────────────────────────────────────
@@ -180,57 +164,50 @@ class TestQuoteVerification:
}
@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"
async def test_sends_to_contact_email(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["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
async def test_html_contains_verify_link(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["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
async def test_html_contains_project_recap(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["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
async def test_uses_first_name_from_contact(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["html"]
assert "Bob" in html
@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."""
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()
await handle_send_quote_verification({
"email": "lead@example.com",
"token": "tok",
"lead_token": "ltok",
})
mock_send_email.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"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["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"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Lead Forward (the money email) ──────────────────────────────
@@ -238,89 +215,71 @@ class TestQuoteVerification:
class TestLeadForward:
@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)
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"
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send_email)["to"] == "supplier@test.com"
@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)
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
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
subject = _call_kwargs(mock_send_email)["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):
async def test_html_contains_heat_badge(self, db, mock_send_email):
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
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
assert "#DC2626" in html # HOT badge color
assert "HOT" in html
@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)
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
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
assert "Indoor" in html
assert "Germany" in html
@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)
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
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["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):
async def test_html_contains_urgency_callout(self, db, mock_send_email):
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
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
# Urgency callout has yellow background
assert "#FEF3C7" in html
@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)
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
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["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):
async def test_uses_leads_from_addr(self, db, mock_send_email):
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"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@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)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
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 = ?",
@@ -331,30 +290,24 @@ class TestLeadForward:
assert row["email_sent_at"] is not None
@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."""
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()
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
mock_send_email.assert_not_called()
@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."""
_, 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()
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
mock_send_email.assert_not_called()
@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)
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"])
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Lead Matched Notification ────────────────────────────────────
@@ -362,70 +315,55 @@ class TestLeadForward:
class TestLeadMatched:
@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)
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"
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send_email)["to"] == "lead@buyer.com"
@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)
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"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert "John" in _call_kwargs(mock_send_email)["subject"]
@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)
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
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send_email)["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):
async def test_html_contains_project_context(self, db, mock_send_email):
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
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send_email)["html"]
assert "Indoor" in html
assert "Germany" in html
@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)
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"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send_email)["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()
async def test_skips_when_lead_not_found(self, db, mock_send_email):
await handle_send_lead_matched_notification({"lead_id": 99999})
mock_send_email.assert_not_called()
@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="")
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()
await handle_send_lead_matched_notification({"lead_id": lead_id})
mock_send_email.assert_not_called()
@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)
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"])
await handle_send_lead_matched_notification({"lead_id": lead_id})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Supplier Enquiry ─────────────────────────────────────────────
@@ -441,50 +379,43 @@ class TestSupplierEnquiry:
}
@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"
async def test_sends_to_supplier_email(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["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"]
async def test_subject_contains_contact_name(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert "Alice Smith" in _call_kwargs(mock_send_email)["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
async def test_html_contains_message(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["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
async def test_html_contains_respond_fast_nudge(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["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()
async def test_skips_when_no_supplier_email(self, mock_send_email):
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
mock_send_email.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"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["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"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Waitlist (supplement existing test_waitlist.py) ──────────────
@@ -494,33 +425,29 @@ 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
async def test_general_waitlist_has_preheader(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
html = _call_kwargs(mock_send_email)["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()
async def test_supplier_waitlist_mentions_plan(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
kw = _call_kwargs(mock_send_email)
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"])
async def test_general_waitlist_design_elements(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
_assert_common_design(_call_kwargs(mock_send_email)["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
async def test_supplier_waitlist_perks_listed(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
html = _call_kwargs(mock_send_email)["html"]
# Should have <li> perks
assert html.count("<li>") >= 3
# ── DB seed helpers ──────────────────────────────────────────────

View File

@@ -10,7 +10,6 @@ import sqlite3
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics.core import utcnow_iso
from padelnomics.migrations.migrate import migrate
from padelnomics import core
@@ -25,25 +24,6 @@ def mock_csrf_validation():
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 = ""):
"""Insert or replace a flag in the test DB."""
await db.execute(

View File

@@ -46,26 +46,6 @@ def _bypass_csrf():
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(
db,
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 pytest
from padelnomics.core import utcnow_iso
# ── 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
def state_db_dir():
"""Temp directory with a seeded .state.sqlite for testing."""

View File

@@ -10,7 +10,6 @@ Covers:
import json
from unittest.mock import patch
import pytest
from padelnomics.content.health import (
check_broken_scenario_refs,
check_hreflang_orphans,
@@ -27,26 +26,6 @@ from padelnomics import core
# ── 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 ────────────────────────────────────────────────────────────────

View File

@@ -89,26 +89,6 @@ async def articles_data(db, seo_data):
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 ─────────────────────────────────────
class TestSearchPerformance:

View File

@@ -188,59 +188,55 @@ class TestWorkerTask:
"""Test send_waitlist_confirmation worker task."""
@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."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "entrepreneur@example.com",
"intent": "signup",
})
await handle_send_waitlist_confirmation({
"email": "entrepreneur@example.com",
"intent": "signup",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
@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."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_growth",
})
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_growth",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower()
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower()
@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."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_pro",
})
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_pro",
})
call_args = mock_send.call_args
html = call_args.kwargs["html"]
assert "pro" in html.lower()
call_args = mock_send_email.call_args
html = call_args.kwargs["html"]
assert "pro" in html.lower()
@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."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "test@example.com",
"intent": "signup",
})
await handle_send_waitlist_confirmation({
"email": "test@example.com",
"intent": "signup",
})
call_args = mock_send.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
call_args = mock_send_email.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
# ── TestAuthRoutes ────────────────────────────────────────────────