Files
padelnomics/web/tests/test_content.py
Deeman 2521ba61b6 fix(test): correct article slug in test_article_url_and_title
Article slug is template_slug + city_slug ("city-cost-miami"),
not just the city slug ("miami").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:48:43 +01:00

1114 lines
46 KiB
Python

"""
Tests for the programmatic SEO content generation engine.
Covers: slugify utility, migration 0010, generation pipeline,
scenario widget baking, public article serving, markets hub,
sitemap integration, admin CRUD routes, and path collision prevention.
"""
import importlib
import json
import sqlite3
from datetime import date, datetime
from pathlib import Path
import pytest
from padelnomics.content.routes import (
RESERVED_PREFIXES,
SCENARIO_RE,
SECTION_TEMPLATES,
bake_scenario_cards,
is_reserved_path,
)
from padelnomics.core import execute, fetch_all, fetch_one, slugify
from padelnomics.planner.calculator import calc, validate_state
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "schema.sql"
# ── Helpers ───────────────────────────────────────────────────
def _table_names(conn):
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
" AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
return [r[0] for r in rows]
def _trigger_names(conn):
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name"
).fetchall()
return [r[0] for r in rows]
def _index_names(conn):
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index'"
" AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
return [r[0] for r in rows]
def _column_names(conn, table):
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
async def _create_published_scenario(slug="test-scenario", city="TestCity", country="TC"):
"""Insert a published scenario with real calc data, return its id."""
state = validate_state({"dblCourts": 4, "sglCourts": 2})
d = calc(state)
return await execute(
"""INSERT INTO published_scenarios
(slug, title, subtitle, location, country, venue_type, ownership,
court_config, state_json, calc_json)
VALUES (?, ?, '', ?, ?, 'indoor', 'rent', '4 double + 2 single', ?, ?)""",
(slug, f"Scenario {city}", city, country, json.dumps(state), json.dumps(d)),
)
async def _create_article(slug="test-article", url_path="/test-article",
status="published", published_at=None):
"""Insert an article row, return its id."""
pub = published_at or datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
return await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, slug, f"Title {slug}", f"Desc {slug}", "US", "North America",
status, pub),
)
async def _create_template():
"""Insert a template + 3 data rows, return (template_id, data_row_count)."""
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
"City Cost Analysis", "city-cost", "calculator",
json.dumps([
{"name": "city", "label": "City", "field_type": "text", "required": True},
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True},
{"name": "country", "label": "Country", "field_type": "text", "required": True},
{"name": "region", "label": "Region", "field_type": "text", "required": False},
{"name": "electricity", "label": "Electricity", "field_type": "number", "required": False},
]),
"/padel-court-cost-{{ city_slug }}",
"Padel Center Cost in {{ city }}",
"How much does a padel center cost in {{ city }}?",
"# Padel in {{ city }}\n\n[scenario:{{ scenario_slug }}]\n\n## CAPEX\n\n[scenario:{{ scenario_slug }}:capex]",
),
)
cities = [
("Miami", "miami", "US", "North America", 700),
("Madrid", "madrid", "ES", "Europe", 500),
("Berlin", "berlin", "DE", "Europe", 550),
]
for city, slug, country, region, elec in cities:
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps({
"city": city, "city_slug": slug, "country": country,
"region": region, "electricity": elec,
})),
)
return template_id, len(cities)
# ════════════════════════════════════════════════════════════
# slugify()
# ════════════════════════════════════════════════════════════
class TestSlugify:
def test_basic(self):
assert slugify("Hello World") == "hello-world"
def test_unicode_stripped(self):
assert slugify("Padel Court Cost in München") == "padel-court-cost-in-munchen"
def test_special_chars_removed(self):
assert slugify("Test/Special@Chars!") == "testspecialchars"
def test_max_length(self):
result = slugify("a " * 100, max_length_chars=20)
assert len(result) <= 20
def test_empty_string(self):
assert slugify("") == ""
def test_leading_trailing_hyphens_stripped(self):
assert slugify("---hello---") == "hello"
def test_multiple_spaces_collapsed(self):
assert slugify("hello world") == "hello-world"
def test_accented_characters(self):
assert slugify("café résumé naïve") == "cafe-resume-naive"
# ════════════════════════════════════════════════════════════
# Migration 0010
# ════════════════════════════════════════════════════════════
class TestMigration0010:
"""Synchronous tests — migration uses stdlib sqlite3."""
def _run_migration(self, db_path):
conn = sqlite3.connect(db_path)
mod = importlib.import_module("padelnomics.migrations.versions.0010_add_content_tables")
mod.up(conn)
conn.commit()
return conn
def test_creates_all_tables(self, tmp_path):
conn = self._run_migration(str(tmp_path / "test.db"))
tables = _table_names(conn)
assert "published_scenarios" in tables
assert "article_templates" in tables
assert "template_data" in tables
assert "articles" in tables
conn.close()
def test_creates_fts_table(self, tmp_path):
conn = self._run_migration(str(tmp_path / "test.db"))
tables = _table_names(conn)
assert "articles_fts" in tables
conn.close()
def test_creates_sync_triggers(self, tmp_path):
conn = self._run_migration(str(tmp_path / "test.db"))
triggers = _trigger_names(conn)
assert "articles_ai" in triggers
assert "articles_ad" in triggers
assert "articles_au" in triggers
conn.close()
def test_creates_indexes(self, tmp_path):
conn = self._run_migration(str(tmp_path / "test.db"))
indexes = _index_names(conn)
assert "idx_pub_scenarios_slug" in indexes
assert "idx_article_templates_slug" in indexes
assert "idx_template_data_template" in indexes
assert "idx_articles_url_path" in indexes
assert "idx_articles_slug" in indexes
assert "idx_articles_status" in indexes
conn.close()
def test_published_scenarios_columns(self, tmp_path):
conn = self._run_migration(str(tmp_path / "test.db"))
cols = _column_names(conn, "published_scenarios")
for expected in ("id", "slug", "title", "location", "country",
"venue_type", "ownership", "court_config",
"state_json", "calc_json", "template_data_id"):
assert expected in cols, f"{expected} missing from published_scenarios"
conn.close()
def test_articles_columns(self, tmp_path):
conn = self._run_migration(str(tmp_path / "test.db"))
cols = _column_names(conn, "articles")
for expected in ("id", "url_path", "slug", "title", "meta_description",
"country", "region", "status", "published_at",
"template_data_id"):
assert expected in cols, f"{expected} missing from articles"
conn.close()
def test_idempotent(self, tmp_path):
"""Running the migration twice does not raise."""
db_path = str(tmp_path / "test.db")
conn = self._run_migration(db_path)
conn.close()
# Run again
conn = self._run_migration(db_path)
tables = _table_names(conn)
assert "articles" in tables
conn.close()
def test_fts_sync_on_insert(self, tmp_path):
"""Inserting into articles populates articles_fts via trigger."""
conn = self._run_migration(str(tmp_path / "test.db"))
conn.execute(
"""INSERT INTO articles (url_path, slug, title, meta_description,
country, region, status, published_at)
VALUES ('/test', 'test', 'Test Title', 'desc', 'US', 'NA',
'published', '2026-01-01')"""
)
conn.commit()
rows = conn.execute(
"SELECT * FROM articles_fts WHERE articles_fts MATCH 'Test'"
).fetchall()
assert len(rows) == 1
conn.close()
def test_fts_sync_on_delete(self, tmp_path):
"""Deleting from articles removes from articles_fts via trigger."""
conn = self._run_migration(str(tmp_path / "test.db"))
conn.execute(
"""INSERT INTO articles (url_path, slug, title, meta_description,
country, region, status, published_at)
VALUES ('/test', 'test', 'UniqueTitle', 'desc', 'US', 'NA',
'published', '2026-01-01')"""
)
conn.commit()
conn.execute("DELETE FROM articles WHERE slug = 'test'")
conn.commit()
rows = conn.execute(
"SELECT * FROM articles_fts WHERE articles_fts MATCH 'UniqueTitle'"
).fetchall()
assert len(rows) == 0
conn.close()
# ════════════════════════════════════════════════════════════
# Scenario regex
# ════════════════════════════════════════════════════════════
class TestScenarioRegex:
def test_simple_marker(self):
m = SCENARIO_RE.search("[scenario:my-slug]")
assert m.group(1) == "my-slug"
assert m.group(2) is None
def test_section_marker(self):
m = SCENARIO_RE.search("[scenario:my-slug:capex]")
assert m.group(1) == "my-slug"
assert m.group(2) == "capex"
def test_all_sections_have_templates(self):
for section in (None, "capex", "operating", "cashflow", "returns", "full"):
assert section in SECTION_TEMPLATES
def test_no_match_on_invalid(self):
assert SCENARIO_RE.search("[scenario:UPPER]") is None
assert SCENARIO_RE.search("[scenario:]") is None
# ════════════════════════════════════════════════════════════
# Path collision prevention
# ════════════════════════════════════════════════════════════
class TestReservedPaths:
def test_admin_reserved(self):
assert is_reserved_path("/admin/anything") is True
def test_planner_reserved(self):
assert is_reserved_path("/planner/") is True
def test_markets_reserved(self):
assert is_reserved_path("/markets") is True
def test_custom_path_allowed(self):
assert is_reserved_path("/padel-court-cost-miami") is False
def test_leading_slash_normalized(self):
assert is_reserved_path("admin/foo") is True
def test_all_prefixes_covered(self):
"""Every prefix in the tuple is actually checked."""
for prefix in RESERVED_PREFIXES:
assert is_reserved_path(prefix + "/test") is True
# ════════════════════════════════════════════════════════════
# Scenario card baking
# ════════════════════════════════════════════════════════════
class TestBakeScenarioCards:
async def test_no_markers_returns_unchanged(self, db):
html = "<p>Hello world</p>"
result = await bake_scenario_cards(html)
assert result == html
async def test_unknown_slug_left_as_marker(self, db):
"""Markers referencing non-existent scenarios are removed (replaced with nothing)."""
html = "<p>[scenario:nonexistent]</p>"
result = await bake_scenario_cards(html)
# The marker position is replaced but scenario not found → no replacement
assert "scenario-widget" not in result
async def test_summary_card_baked(self, db):
await _create_published_scenario(slug="test-bake")
html = "<div>[scenario:test-bake]</div>"
result = await bake_scenario_cards(html)
assert "scenario-widget" in result
assert "scenario-summary" in result
assert "TestCity" in result
async def test_capex_section_baked(self, db):
await _create_published_scenario(slug="test-capex")
html = "[scenario:test-capex:capex]"
result = await bake_scenario_cards(html)
assert "scenario-capex" in result
assert "Investment Breakdown" in result
async def test_operating_section_baked(self, db):
await _create_published_scenario(slug="test-op")
html = "[scenario:test-op:operating]"
result = await bake_scenario_cards(html)
assert "scenario-operating" in result
async def test_cashflow_section_baked(self, db):
await _create_published_scenario(slug="test-cf")
html = "[scenario:test-cf:cashflow]"
result = await bake_scenario_cards(html)
assert "scenario-cashflow" in result
async def test_returns_section_baked(self, db):
await _create_published_scenario(slug="test-ret")
html = "[scenario:test-ret:returns]"
result = await bake_scenario_cards(html)
assert "scenario-returns" in result
async def test_full_section_baked(self, db):
await _create_published_scenario(slug="test-full")
html = "[scenario:test-full:full]"
result = await bake_scenario_cards(html)
# Full includes all sections
assert "scenario-summary" in result
assert "scenario-capex" in result
assert "scenario-operating" in result
assert "scenario-cashflow" in result
assert "scenario-returns" in result
async def test_multiple_markers_same_slug(self, db):
await _create_published_scenario(slug="multi")
html = "[scenario:multi]\n---\n[scenario:multi:capex]"
result = await bake_scenario_cards(html)
assert "scenario-summary" in result
assert "scenario-capex" in result
async def test_planner_cta_link(self, db):
await _create_published_scenario(slug="cta-test")
html = "[scenario:cta-test]"
result = await bake_scenario_cards(html)
assert "planner" in result
assert "Try with your own numbers" in result
async def test_invalid_section_ignored(self, db):
await _create_published_scenario(slug="bad-sec")
html = "[scenario:bad-sec:nonexistent]"
result = await bake_scenario_cards(html)
# Invalid section key → no template → marker untouched
assert "scenario-widget" not in result
# ════════════════════════════════════════════════════════════
# Generation pipeline
# ════════════════════════════════════════════════════════════
class TestGenerationPipeline:
async def test_generates_correct_count(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, count = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
generated = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
assert generated == count
async def test_staggered_dates_two_per_day(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 2)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
assert len(articles) == 3
dates = [a["published_at"][:10] for a in articles]
# 2 on day 1, 1 on day 2
assert dates[0] == "2026-03-01"
assert dates[1] == "2026-03-01"
assert dates[2] == "2026-03-02"
async def test_staggered_dates_one_per_day(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 1)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
dates = sorted({a["published_at"][:10] for a in articles})
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
async def test_article_url_and_title(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'")
assert miami is not None
assert miami["url_path"] == "/padel-court-cost-miami"
assert miami["title"] == "Padel Center Cost in Miami"
assert miami["country"] == "US"
assert miami["region"] == "North America"
assert miami["status"] == "published"
async def test_scenario_created_per_row(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, count = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
scenarios = await fetch_all("SELECT * FROM published_scenarios")
assert len(scenarios) == count
async def test_scenario_has_valid_calc_json(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
)
assert scenario is not None
d = json.loads(scenario["calc_json"])
assert "capex" in d
assert "ebitdaMonth" in d
assert "irr" in d
assert d["capex"] > 0
async def test_template_data_linked(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
for row in rows:
assert row["article_id"] is not None, f"Row {row['id']} not linked to article"
assert row["scenario_id"] is not None, f"Row {row['id']} not linked to scenario"
async def test_build_files_written(self, db):
from padelnomics.admin.routes import _generate_from_template
from padelnomics.content.routes import BUILD_DIR
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
articles = await fetch_all("SELECT slug FROM articles")
try:
for a in articles:
build_path = BUILD_DIR / f"{a['slug']}.html"
assert build_path.exists(), f"Missing build file: {build_path}"
content = build_path.read_text()
assert len(content) > 100, f"Build file too small: {build_path}"
assert "scenario-widget" in content
finally:
# Cleanup build files
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
async def test_skips_already_generated(self, db):
"""Running generate twice does not duplicate articles."""
from padelnomics.admin.routes import _generate_from_template
template_id, count = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
assert first == count
# Second run: all rows already linked → 0 generated
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10)
assert second == 0
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == count
# Cleanup
from padelnomics.content.routes import BUILD_DIR
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
async def test_calc_overrides_applied(self, db):
"""Data row values that match DEFAULTS keys are used as calc overrides."""
from padelnomics.admin.routes import _generate_from_template
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
# Miami had electricity=700, default is 600
scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
)
state = json.loads(scenario["state_json"])
assert state["electricity"] == 700
# Cleanup
from padelnomics.content.routes import BUILD_DIR
for slug in ("miami", "madrid", "berlin"):
p = BUILD_DIR / f"{slug}.html"
if p.exists():
p.unlink()
# ════════════════════════════════════════════════════════════
# Jinja string rendering
# ════════════════════════════════════════════════════════════
class TestRenderJinjaString:
def test_simple(self):
from padelnomics.admin.routes import _render_jinja_string
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
def test_missing_var_empty(self):
from padelnomics.admin.routes import _render_jinja_string
result = _render_jinja_string("Hello {{ missing }}!", {})
assert result == "Hello !"
def test_url_pattern(self):
from padelnomics.admin.routes import _render_jinja_string
result = _render_jinja_string("/padel-court-cost-{{ slug }}", {"slug": "miami"})
assert result == "/padel-court-cost-miami"
# ════════════════════════════════════════════════════════════
# Public routes
# ════════════════════════════════════════════════════════════
class TestMarketsHub:
async def test_markets_returns_200(self, client):
resp = await client.get("/en/markets")
assert resp.status_code == 200
async def test_markets_has_search(self, client):
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert 'id="market-q"' in html
async def test_markets_results_partial(self, client):
resp = await client.get("/en/markets/results")
assert resp.status_code == 200
async def test_markets_shows_published_articles(self, client, db):
await _create_article(slug="pub-test", url_path="/pub-test", status="published")
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert "Title pub-test" in html
async def test_markets_hides_draft_articles(self, client, db):
await _create_article(slug="draft-test", url_path="/draft-test", status="draft")
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert "Title draft-test" not in html
async def test_markets_hides_future_articles(self, client, db):
await _create_article(
slug="future-test", url_path="/future-test",
status="published", published_at="2099-01-01T00:00:00",
)
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert "Title future-test" not in html
async def test_markets_filter_by_country(self, client, db):
await _create_article(slug="us-art", url_path="/us-art")
resp = await client.get("/en/markets/results?country=US")
html = (await resp.data).decode()
assert "Title us-art" in html
resp = await client.get("/en/markets/results?country=DE")
html = (await resp.data).decode()
assert "Title us-art" not in html
class TestArticleServing:
async def test_nonexistent_article_returns_404(self, client):
resp = await client.get("/en/padel-court-cost-nonexistent")
assert resp.status_code == 404
async def test_draft_article_returns_404(self, client, db):
await _create_article(slug="draft-serve", url_path="/draft-serve", status="draft")
resp = await client.get("/en/draft-serve")
assert resp.status_code == 404
async def test_future_article_returns_404(self, client, db):
await _create_article(
slug="future-serve", url_path="/future-serve",
status="published", published_at="2099-01-01T00:00:00",
)
resp = await client.get("/en/future-serve")
assert resp.status_code == 404
async def test_published_article_served(self, client, db):
from padelnomics.content.routes import BUILD_DIR
await _create_article(slug="live-art", url_path="/live-art")
# Write a build file
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_path = BUILD_DIR / "live-art.html"
build_path.write_text("<p>Article body content</p>")
try:
resp = await client.get("/en/live-art")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Article body content" in html
assert "Title live-art" in html
finally:
if build_path.exists():
build_path.unlink()
async def test_article_missing_build_file_returns_404(self, client, db):
await _create_article(slug="no-build", url_path="/no-build")
resp = await client.get("/en/no-build")
assert resp.status_code == 404
# ════════════════════════════════════════════════════════════
# Sitemap integration
# ════════════════════════════════════════════════════════════
class TestSitemapContent:
async def test_sitemap_includes_markets(self, client):
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/markets" in xml
async def test_sitemap_includes_published_articles(self, client, db):
await _create_article(slug="sitemap-art", url_path="/sitemap-art")
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/sitemap-art" in xml
async def test_sitemap_excludes_draft_articles(self, client, db):
await _create_article(slug="sitemap-draft", url_path="/sitemap-draft", status="draft")
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/sitemap-draft" not in xml
async def test_sitemap_excludes_future_articles(self, client, db):
await _create_article(
slug="sitemap-future", url_path="/sitemap-future",
status="published", published_at="2099-01-01T00:00:00",
)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/sitemap-future" not in xml
# ════════════════════════════════════════════════════════════
# Route registration
# ════════════════════════════════════════════════════════════
class TestRouteRegistration:
def test_markets_route_registered(self, app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert "/<lang>/markets" in rules
def test_admin_content_routes_registered(self, app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert "/admin/templates" in rules
assert "/admin/scenarios" in rules
assert "/admin/articles" in rules
assert "/admin/scenarios/new" in rules
assert "/admin/rebuild-all" in rules
def test_catchall_route_registered(self, app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert "/<lang>/<path:url_path>" in rules
# ════════════════════════════════════════════════════════════
# Admin routes (require admin session)
# ════════════════════════════════════════════════════════════
@pytest.fixture
async def admin_client(app, db):
"""Test client with admin user (has admin role)."""
from datetime import datetime
now = datetime.utcnow().isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
class TestAdminTemplates:
async def test_template_list_requires_admin(self, client):
resp = await client.get("/admin/templates")
assert resp.status_code == 302
async def test_template_list(self, admin_client):
resp = await admin_client.get("/admin/templates")
assert resp.status_code == 200
async def test_template_new_form(self, admin_client):
resp = await admin_client.get("/admin/templates/new")
assert resp.status_code == 200
async def test_template_create(self, admin_client, db):
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/templates/new", form={
"csrf_token": "test",
"name": "Test Template",
"slug": "test-tmpl",
"content_type": "calculator",
"input_schema": '[{"name":"city","label":"City","field_type":"text","required":true}]',
"url_pattern": "/test-{{ city }}",
"title_pattern": "Test {{ city }}",
"meta_description_pattern": "",
"body_template": "# Hello {{ city }}",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE slug = 'test-tmpl'")
assert row is not None
assert row["name"] == "Test Template"
async def test_template_edit(self, admin_client, db):
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, body_template)
VALUES ('Edit Me', 'edit-me', 'calculator', '[]',
'/edit', 'Edit', '# body')"""
)
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", form={
"csrf_token": "test",
"name": "Edited",
"input_schema": "[]",
"url_pattern": "/edit",
"title_pattern": "Edited",
"body_template": "# edited",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
assert row["name"] == "Edited"
async def test_template_delete(self, admin_client, db):
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, body_template)
VALUES ('Del Me', 'del-me', 'calculator', '[]',
'/del', 'Del', '# body')"""
)
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", form={
"csrf_token": "test",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
assert row is None
class TestAdminScenarios:
async def test_scenario_list(self, admin_client):
resp = await admin_client.get("/admin/scenarios")
assert resp.status_code == 200
async def test_scenario_new_form(self, admin_client):
resp = await admin_client.get("/admin/scenarios/new")
assert resp.status_code == 200
async def test_scenario_create(self, admin_client, db):
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/scenarios/new", form={
"csrf_token": "test",
"title": "Miami Test",
"slug": "miami-test",
"location": "Miami",
"country": "US",
"venue": "indoor",
"own": "rent",
"dblCourts": "4",
"sglCourts": "2",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM published_scenarios WHERE slug = 'miami-test'")
assert row is not None
assert row["title"] == "Miami Test"
d = json.loads(row["calc_json"])
assert d["capex"] > 0
async def test_scenario_edit_recalculates(self, admin_client, db):
scenario_id = await _create_published_scenario(slug="edit-sc")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/scenarios/{scenario_id}/edit", form={
"csrf_token": "test",
"title": "Updated",
"location": "TestCity",
"country": "TC",
"dblCourts": "6",
"sglCourts": "0",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
assert row["title"] == "Updated"
state = json.loads(row["state_json"])
assert state["dblCourts"] == 6
async def test_scenario_delete(self, admin_client, db):
scenario_id = await _create_published_scenario(slug="del-sc")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/scenarios/{scenario_id}/delete", form={
"csrf_token": "test",
})
assert resp.status_code == 302
assert await fetch_one("SELECT 1 FROM published_scenarios WHERE id = ?", (scenario_id,)) is None
async def test_scenario_preview(self, admin_client, db):
scenario_id = await _create_published_scenario(slug="preview-sc")
resp = await admin_client.get(f"/admin/scenarios/{scenario_id}/preview")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "scenario-widget" in html
class TestAdminArticles:
async def test_article_list(self, admin_client):
resp = await admin_client.get("/admin/articles")
assert resp.status_code == 200
async def test_article_new_form(self, admin_client):
resp = await admin_client.get("/admin/articles/new")
assert resp.status_code == 200
async def test_article_create_manual(self, admin_client, db):
from padelnomics.content.routes import BUILD_DIR
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/articles/new", form={
"csrf_token": "test",
"title": "Manual Article",
"slug": "manual-art",
"url_path": "/manual-art",
"meta_description": "A test article",
"country": "US",
"region": "North America",
"body": "# Hello\n\nThis is a test.",
"status": "published",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM articles WHERE slug = 'manual-art'")
assert row is not None
assert row["title"] == "Manual Article"
# Build file created
build_path = BUILD_DIR / "manual-art.html"
try:
assert build_path.exists()
content = build_path.read_text()
assert "<h1>Hello</h1>" in content
finally:
if build_path.exists():
build_path.unlink()
md_path = Path("data/content/articles/manual-art.md")
if md_path.exists():
md_path.unlink()
async def test_article_reserved_path_rejected(self, admin_client, db):
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/articles/new", form={
"csrf_token": "test",
"title": "Bad Path",
"slug": "bad-path",
"url_path": "/admin/evil",
"body": "# Nope",
"status": "draft",
})
# Should re-render form with error, not redirect
assert resp.status_code == 200
html = (await resp.data).decode()
assert "reserved" in html.lower() or "conflict" in html.lower()
async def test_article_publish_toggle(self, admin_client, db):
article_id = await _create_article(slug="toggle-art", url_path="/toggle-art", status="draft")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/articles/{article_id}/publish", form={
"csrf_token": "test",
})
assert resp.status_code == 302
row = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
assert row["status"] == "published"
# Toggle back
resp = await admin_client.post(f"/admin/articles/{article_id}/publish", form={
"csrf_token": "test",
})
row = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
assert row["status"] == "draft"
async def test_article_delete(self, admin_client, db):
article_id = await _create_article(slug="del-art", url_path="/del-art")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/articles/{article_id}/delete", form={
"csrf_token": "test",
})
assert resp.status_code == 302
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
class TestAdminTemplateData:
async def test_data_add(self, admin_client, db):
template_id, _ = await _create_template()
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/data/add", form={
"csrf_token": "test",
"city": "London",
"city_slug": "london",
"country": "UK",
"region": "Europe",
"electricity": "650",
})
assert resp.status_code == 302
rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
# 3 from _create_template + 1 just added
assert len(rows) == 4
async def test_data_delete(self, admin_client, db):
template_id, _ = await _create_template()
rows = await fetch_all(
"SELECT id FROM template_data WHERE template_id = ?", (template_id,)
)
data_id = rows[0]["id"]
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(
f"/admin/templates/{template_id}/data/{data_id}/delete",
form={"csrf_token": "test"},
)
assert resp.status_code == 302
remaining = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
assert len(remaining) == 2
class TestAdminGenerate:
async def test_generate_form(self, admin_client, db):
template_id, _ = await _create_template()
resp = await admin_client.get(f"/admin/templates/{template_id}/generate")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "3" in html # pending count
async def test_generate_creates_articles(self, admin_client, db):
from padelnomics.content.routes import BUILD_DIR
template_id, _ = await _create_template()
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/generate", form={
"csrf_token": "test",
"start_date": "2026-04-01",
"articles_per_day": "2",
})
assert resp.status_code == 302
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
# Cleanup
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
# ════════════════════════════════════════════════════════════
# Admin dashboard quick links
# ════════════════════════════════════════════════════════════
class TestAdminDashboardLinks:
async def test_dashboard_has_content_links(self, admin_client):
resp = await admin_client.get("/admin/")
html = (await resp.data).decode()
assert "/admin/templates" in html
assert "/admin/scenarios" in html
assert "/admin/articles" in html
# ════════════════════════════════════════════════════════════
# Footer
# ════════════════════════════════════════════════════════════
class TestFooterMarkets:
async def test_footer_has_markets_link(self, client):
resp = await client.get("/en/")
html = (await resp.data).decode()
assert "/markets" in html