Compare commits
14 Commits
v202603020
...
v202603021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48401bd2af | ||
|
|
cd02726d4c | ||
|
|
fbc259cafa | ||
|
|
992e448c18 | ||
|
|
777a4af505 | ||
|
|
2c8c662e9e | ||
|
|
34f8e45204 | ||
|
|
6b9187f420 | ||
|
|
94d92328b8 | ||
|
|
100e200c3b | ||
|
|
70628ea881 | ||
|
|
d619f5e1ef | ||
|
|
2a7eed1576 | ||
|
|
162e633c62 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
|
||||||
|
- `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
|
||||||
|
- `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
|
||||||
|
- `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
|
||||||
|
- `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
|
||||||
|
- `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
|
||||||
|
- `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
|
||||||
|
- CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles
|
||||||
|
|
||||||
|
|
||||||
|
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
|
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
|
||||||
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
|
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
|
||||||
@@ -13,6 +26,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.
|
||||||
|
|||||||
@@ -160,4 +160,10 @@ Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten
|
|||||||
|
|
||||||
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
|
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
|
||||||
|
|
||||||
[→ Businessplan erstellen]
|
[→ Businessplan erstellen](/de/planner)
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
|
||||||
|
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -160,4 +160,10 @@ A bankable business plan depends on the quality of the financial model behind it
|
|||||||
|
|
||||||
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
|
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
|
||||||
|
|
||||||
[→ Generate your business plan]
|
[→ Generate your business plan](/en/planner)
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
|
||||||
|
<a href="/quote" class="btn">Request a Quote</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -331,8 +331,10 @@ Building a padel hall is complex, but it is a solved problem. The failures are n
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Find Builders and Suppliers Through Padelnomics
|
## Find the Right Build Partners
|
||||||
|
|
||||||
Padelnomics maintains a directory of verified build partners for padel hall projects: architects with sports facility experience, court suppliers, HVAC specialists, and operational consultants.
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
|
||||||
If you're currently in Phase 1 or Phase 2 and looking for the right partners, the directory is the fastest place to start.
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
|
||||||
|
<a href="/quote" class="btn">Request a Quote</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -191,4 +191,10 @@ Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on
|
|||||||
|
|
||||||
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
|
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
|
||||||
|
|
||||||
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
|
**Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
|
||||||
|
<a href="/quote" class="btn">Request a Quote</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -179,3 +179,9 @@ Your most powerful tool in every bank meeting: a complete financial model demons
|
|||||||
[scenario:padel-halle-6-courts:full]
|
[scenario:padel-halle-6-courts:full]
|
||||||
|
|
||||||
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
|
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
|
||||||
|
<a href="/quote" class="btn">Request a Quote</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ The investors who succeed long-term in padel aren't the ones who found a risk-fr
|
|||||||
|
|
||||||
## Model the Downside with Padelnomics
|
## Model the Downside with Padelnomics
|
||||||
|
|
||||||
The Padelnomics investment planner includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
|
The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
|
||||||
|
|
||||||
Good decisions need an honest model — not just the best-case assumptions.
|
Good decisions need an honest model — not just the best-case assumptions.
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
|
||||||
|
<a href="/quote" class="btn">Request a Quote</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -184,4 +184,10 @@ Padelnomics tracks venue density, booking platform utilisation, and demographic
|
|||||||
|
|
||||||
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
|
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
|
||||||
|
|
||||||
[→ Run a location analysis]
|
[→ Run a location analysis](/en/planner)
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
|
||||||
|
<a href="/quote" class="btn">Request a Quote</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -326,8 +326,10 @@ Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehl
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Planer und Lieferanten finden
|
## Die richtigen Baupartner finden
|
||||||
|
|
||||||
Padelnomics führt ein Verzeichnis verifizierter Baupartner für Padelhallen im DACH-Raum: Architekten mit Sportanlagenerfahrung, Court-Lieferanten, Haustechnikspezialisten und Betriebsberater.
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
|
||||||
Wenn Sie gerade in Phase 1 oder Phase 2 sind und die richtigen Partner suchen, ist das Verzeichnis der schnellste Einstieg.
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
|
||||||
|
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -199,3 +199,9 @@ Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell
|
|||||||
[scenario:padel-halle-6-courts:full]
|
[scenario:padel-halle-6-courts:full]
|
||||||
|
|
||||||
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
|
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
|
||||||
|
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -189,4 +189,10 @@ Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,
|
|||||||
|
|
||||||
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 3–5 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
|
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 3–5 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
|
||||||
|
|
||||||
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
|
**Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
|
||||||
|
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -216,6 +216,12 @@ Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfo
|
|||||||
|
|
||||||
## Die Padelnomics-Investitionsrechnung
|
## Die Padelnomics-Investitionsrechnung
|
||||||
|
|
||||||
Der Padelnomics-Planer enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
|
Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
|
||||||
|
|
||||||
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
|
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
|
||||||
|
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -174,4 +174,10 @@ Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografisch
|
|||||||
|
|
||||||
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
|
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
|
||||||
|
|
||||||
[→ Standortanalyse starten]
|
[→ Standortanalyse starten](/de/planner)
|
||||||
|
|
||||||
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||||
|
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
|
||||||
|
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
|
||||||
|
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -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)
|
_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:
|
async def _sync_static_articles() -> None:
|
||||||
"""Upsert static .md articles from data/content/articles/ into the DB.
|
"""Upsert static .md articles from data/content/articles/ into the DB.
|
||||||
|
|
||||||
@@ -2437,11 +2458,11 @@ async def article_new():
|
|||||||
|
|
||||||
if not title or not body:
|
if not title or not body:
|
||||||
await flash("Title and body are required.", "error")
|
await flash("Title and body are required.", "error")
|
||||||
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
|
||||||
|
|
||||||
if is_reserved_path(url_path):
|
if is_reserved_path(url_path):
|
||||||
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||||
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
|
||||||
|
|
||||||
# Render markdown → HTML with scenario + product cards baked in
|
# Render markdown → HTML with scenario + product cards baked in
|
||||||
body_html = mistune.html(body)
|
body_html = mistune.html(body)
|
||||||
@@ -2474,7 +2495,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, preview_doc="")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
|
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
|
||||||
@@ -2510,7 +2531,7 @@ async def article_edit(article_id: int):
|
|||||||
if is_reserved_path(url_path):
|
if is_reserved_path(url_path):
|
||||||
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id,
|
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Re-render if body provided
|
# Re-render if body provided
|
||||||
@@ -2544,18 +2565,55 @@ 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 = _find_article_md(article["slug"])
|
||||||
if not md_path.exists():
|
if md_path is None:
|
||||||
lang = article["language"] or "en"
|
lang = article["language"] or "en"
|
||||||
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
|
fallback = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
|
||||||
body = md_path.read_text() if md_path.exists() else ""
|
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 ""
|
||||||
|
css_url = url_for("static", filename="css/output.css")
|
||||||
|
preview_doc = (
|
||||||
|
f"<!doctype html><html><head>"
|
||||||
|
f"<link rel='stylesheet' href='{css_url}'>"
|
||||||
|
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||||
|
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||||
|
) if body_html 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,
|
||||||
|
preview_doc=preview_doc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
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 = mistune.html(body) if body else ""
|
||||||
|
css_url = url_for("static", filename="css/output.css")
|
||||||
|
preview_doc = (
|
||||||
|
f"<!doctype html><html><head>"
|
||||||
|
f"<link rel='stylesheet' href='{css_url}'>"
|
||||||
|
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||||
|
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||||
|
) if body_html else ""
|
||||||
|
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -1,89 +1,413 @@
|
|||||||
{% 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: #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 — iframe fills the content area */
|
||||||
|
#ae-preview-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible metadata */
|
||||||
|
.ae-meta--collapsed { display: none; }
|
||||||
|
|
||||||
|
.ae-toolbar__toggle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748B;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
|
||||||
|
|
||||||
|
/* Word count footer */
|
||||||
|
.ae-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.25rem 0.875rem;
|
||||||
|
background: #F8FAFC;
|
||||||
|
border-top: 1px solid #E2E8F0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ae-wordcount {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 %}
|
{% 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">← 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 type="button" class="ae-toolbar__toggle"
|
||||||
|
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
|
||||||
|
<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 class="ae-footer">
|
||||||
|
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
|
<!-- Right — Rendered preview -->
|
||||||
</form>
|
<div class="ae-pane ae-pane--preview">
|
||||||
</div>
|
<div class="ae-pane__header">
|
||||||
|
<span class="ae-pane__label">Preview</span>
|
||||||
|
<span id="ae-loading" class="ae-loading">Rendering…</span>
|
||||||
|
</div>
|
||||||
|
<div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
|
||||||
|
{% if preview_doc %}
|
||||||
|
<iframe
|
||||||
|
srcdoc="{{ preview_doc | e }}"
|
||||||
|
style="flex:1;width:100%;border:none;display:block;"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
title="Article preview"
|
||||||
|
></iframe>
|
||||||
|
{% else %}
|
||||||
|
<p class="preview-placeholder">Start writing to see a preview.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var textarea = document.getElementById('body');
|
||||||
|
var counter = document.getElementById('ae-wordcount');
|
||||||
|
function updateCount() {
|
||||||
|
var text = textarea.value.trim();
|
||||||
|
var count = text ? text.split(/\s+/).length : 0;
|
||||||
|
counter.textContent = count + (count === 1 ? ' word' : ' words');
|
||||||
|
}
|
||||||
|
textarea.addEventListener('input', updateCount);
|
||||||
|
updateCount();
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{# HTMX partial: sandboxed iframe showing a rendered article preview.
|
||||||
|
Rendered by POST /admin/articles/preview. #}
|
||||||
|
{% if preview_doc %}
|
||||||
|
<iframe
|
||||||
|
srcdoc="{{ preview_doc | e }}"
|
||||||
|
style="flex:1;width:100%;border:none;display:block;"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
title="Article preview"
|
||||||
|
></iframe>
|
||||||
|
{% else %}
|
||||||
|
<p class="preview-placeholder">Start writing to see a preview.</p>
|
||||||
|
{% endif %}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user