feat: pSEO CMS — SSG architecture with git templates + DuckDB

# Conflicts:
#	web/pyproject.toml
This commit is contained in:
Deeman
2026-02-23 12:51:30 +01:00
15 changed files with 1474 additions and 874 deletions

View File

@@ -81,43 +81,67 @@ async def _create_article(slug="test-article", url_path="/test-article",
)
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]",
),
)
TEST_TEMPLATE = """\
---
name: "Test City Analysis"
slug: test-city
content_type: calculator
data_table: serving.test_cities
natural_key: city_slug
languages: [en]
url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
title_pattern: "Padel in {{ city }}"
meta_description_pattern: "Padel costs in {{ city }}"
schema_type: Article
---
# Padel in {{ city }}
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)
Welcome to {{ city }}.
[scenario:{{ scenario_slug }}:capex]
"""
TEST_ROWS = [
{"city": "Miami", "city_slug": "miami", "country": "US", "region": "North America", "electricity": 700},
{"city": "Madrid", "city_slug": "madrid", "country": "ES", "region": "Europe", "electricity": 500},
{"city": "Berlin", "city_slug": "berlin", "country": "DE", "region": "Europe", "electricity": 550},
]
TEST_COLUMNS = [
{"column_name": "city", "data_type": "VARCHAR"},
{"column_name": "city_slug", "data_type": "VARCHAR"},
{"column_name": "country", "data_type": "VARCHAR"},
{"column_name": "region", "data_type": "VARCHAR"},
{"column_name": "electricity", "data_type": "INTEGER"},
]
@pytest.fixture
def pseo_env(tmp_path, monkeypatch):
"""Set up pSEO environment: temp template dir, build dir, mock DuckDB."""
import padelnomics.content as content_mod
tpl_dir = tmp_path / "templates"
tpl_dir.mkdir()
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tpl_dir)
build_dir = tmp_path / "build"
build_dir.mkdir()
monkeypatch.setattr(content_mod, "BUILD_DIR", build_dir)
(tpl_dir / "test-city.md.jinja").write_text(TEST_TEMPLATE)
async def mock_fetch_analytics(query, params=None):
if "information_schema" in query:
return TEST_COLUMNS
if "WHERE" in query and params:
# preview_article: filter by natural key value
return [r for r in TEST_ROWS if params[0] in r.values()]
return TEST_ROWS
monkeypatch.setattr(content_mod, "fetch_analytics", mock_fetch_analytics)
return {"tpl_dir": tpl_dir, "build_dir": build_dir}
# ════════════════════════════════════════════════════════════
@@ -401,22 +425,14 @@ class TestBakeScenarioCards:
# ════════════════════════════════════════════════════════════
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_generates_correct_count(self, db, pseo_env):
from padelnomics.content import generate_articles
generated = await generate_articles("test-city", date(2026, 3, 1), 10)
assert generated == 3 # 3 rows × 1 language
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)
async def test_staggered_dates_two_per_day(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 2)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
assert len(articles) == 3
@@ -426,55 +442,39 @@ class TestGenerationPipeline:
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)
async def test_staggered_dates_one_per_day(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", 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)
async def test_article_url_and_title(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'")
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-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["url_path"] == "/en/markets/us/miami"
assert miami["title"] == "Padel in Miami"
assert miami["template_slug"] == "test-city"
assert miami["language"] == "en"
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)
async def test_scenario_created_per_row(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
scenarios = await fetch_all("SELECT * FROM published_scenarios")
assert len(scenarios) == count
assert len(scenarios) == 3
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)
async def test_scenario_has_valid_calc_json(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
"SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
)
assert scenario is not None
d = json.loads(scenario["calc_json"])
@@ -483,112 +483,302 @@ class TestGenerationPipeline:
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)
async def test_build_files_written(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
build_dir = pseo_env["build_dir"]
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()
for a in articles:
build_path = build_dir / "en" / f"{a['slug']}.html"
assert build_path.exists(), f"Missing build file: {build_path}"
content = build_path.read_text()
assert len(content) > 50
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,)
)
async def test_updates_existing_on_regeneration(self, db, pseo_env):
"""Running generate twice updates articles, doesn't duplicate."""
from padelnomics.content import generate_articles
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
assert first == count
first = await generate_articles("test-city", date(2026, 3, 1), 10)
assert first == 3
# Second run: all rows already linked → 0 generated
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10)
assert second == 0
second = await generate_articles("test-city", date(2026, 3, 10), 10)
assert second == 3 # Updates existing
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == count
assert len(articles) == 3 # No duplicates
# 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):
async def test_calc_overrides_applied(self, db, pseo_env):
"""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)
from padelnomics.content import generate_articles
await generate_articles("test-city", 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'"
"SELECT * FROM published_scenarios WHERE slug = 'test-city-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()
async def test_seo_head_populated(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
article = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
assert article["seo_head"] is not None
assert 'rel="canonical"' in article["seo_head"]
assert 'application/ld+json' in article["seo_head"]
# ════════════════════════════════════════════════════════════
# Jinja string rendering
# ════════════════════════════════════════════════════════════
class TestRenderJinjaString:
class TestRenderPattern:
def test_simple(self):
from padelnomics.admin.routes import _render_jinja_string
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
from padelnomics.content import _render_pattern
assert _render_pattern("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 }}!", {})
from padelnomics.content import _render_pattern
result = _render_pattern("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"
from padelnomics.content import _render_pattern
result = _render_pattern("/markets/{{ country | lower }}/{{ slug }}", {"country": "US", "slug": "miami"})
assert result == "/markets/us/miami"
def test_slugify_filter(self):
from padelnomics.content import _render_pattern
result = _render_pattern("{{ name | slugify }}", {"name": "Hello World"})
assert result == "hello-world"
# ════════════════════════════════════════════════════════════
# Template discovery & loading
# ════════════════════════════════════════════════════════════
class TestDiscoverTemplates:
def test_discovers_templates(self, pseo_env):
from padelnomics.content import discover_templates
templates = discover_templates()
assert len(templates) == 1
assert templates[0]["slug"] == "test-city"
assert templates[0]["name"] == "Test City Analysis"
def test_empty_dir(self, tmp_path, monkeypatch):
import padelnomics.content as content_mod
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tmp_path / "nonexistent")
from padelnomics.content import discover_templates
assert discover_templates() == []
def test_skips_invalid_frontmatter(self, pseo_env):
from padelnomics.content import discover_templates
(pseo_env["tpl_dir"] / "bad.md.jinja").write_text("no frontmatter here")
templates = discover_templates()
assert len(templates) == 1 # Only the valid test-city template
def test_includes_path(self, pseo_env):
from padelnomics.content import discover_templates
templates = discover_templates()
assert "_path" in templates[0]
assert templates[0]["_path"].endswith("test-city.md.jinja")
class TestLoadTemplate:
def test_loads_config_and_body(self, pseo_env):
from padelnomics.content import load_template
config = load_template("test-city")
assert config["slug"] == "test-city"
assert config["content_type"] == "calculator"
assert config["data_table"] == "serving.test_cities"
assert "body_template" in config
assert "Padel in {{ city }}" in config["body_template"]
def test_missing_template_raises(self, pseo_env):
from padelnomics.content import load_template
with pytest.raises(AssertionError, match="Template not found"):
load_template("nonexistent")
def test_schema_type_normalized_to_list(self, pseo_env):
from padelnomics.content import load_template
config = load_template("test-city")
assert isinstance(config["schema_type"], list)
assert "Article" in config["schema_type"]
def test_languages_parsed(self, pseo_env):
from padelnomics.content import load_template
config = load_template("test-city")
assert config["languages"] == ["en"]
# ════════════════════════════════════════════════════════════
# FAQ extraction
# ════════════════════════════════════════════════════════════
class TestExtractFaqPairs:
def test_extracts_pairs(self):
from padelnomics.content import _extract_faq_pairs
md = (
"# Title\n\n"
"## FAQ\n\n"
"**How much does it cost?**\n"
"It costs about 500k.\n\n"
"**How long does it take?**\n"
"About 12 months.\n\n"
"## Other Section\n"
)
pairs = _extract_faq_pairs(md)
assert len(pairs) == 2
assert pairs[0]["question"] == "How much does it cost?"
assert "500k" in pairs[0]["answer"]
assert pairs[1]["question"] == "How long does it take?"
def test_no_faq_section(self):
from padelnomics.content import _extract_faq_pairs
assert _extract_faq_pairs("# Title\n\nSome content") == []
def test_faq_at_end_of_document(self):
from padelnomics.content import _extract_faq_pairs
md = "## FAQ\n\n**Question one?**\nAnswer one.\n"
pairs = _extract_faq_pairs(md)
assert len(pairs) == 1
assert pairs[0]["question"] == "Question one?"
assert pairs[0]["answer"] == "Answer one."
# ════════════════════════════════════════════════════════════
# Breadcrumbs
# ════════════════════════════════════════════════════════════
class TestBuildBreadcrumbs:
def test_basic_path(self):
from padelnomics.content import _build_breadcrumbs
crumbs = _build_breadcrumbs("/en/markets/germany/berlin", "https://padelnomics.io")
assert len(crumbs) == 5
assert crumbs[0] == {"name": "Home", "url": "https://padelnomics.io/"}
assert crumbs[1] == {"name": "En", "url": "https://padelnomics.io/en"}
assert crumbs[2] == {"name": "Markets", "url": "https://padelnomics.io/en/markets"}
assert crumbs[3] == {"name": "Germany", "url": "https://padelnomics.io/en/markets/germany"}
assert crumbs[4] == {"name": "Berlin", "url": "https://padelnomics.io/en/markets/germany/berlin"}
def test_root_path(self):
from padelnomics.content import _build_breadcrumbs
crumbs = _build_breadcrumbs("/", "https://padelnomics.io")
assert len(crumbs) == 1
assert crumbs[0]["name"] == "Home"
def test_hyphenated_segments_titlecased(self):
from padelnomics.content import _build_breadcrumbs
crumbs = _build_breadcrumbs("/en/my-section", "https://padelnomics.io")
assert crumbs[2]["name"] == "My Section"
# ════════════════════════════════════════════════════════════
# JSON-LD structured data
# ════════════════════════════════════════════════════════════
class TestBuildJsonld:
_COMMON = dict(
title="Test Title",
description="Test description",
url="https://padelnomics.io/en/markets/us/miami",
published_at="2026-01-01T08:00:00",
date_modified="2026-01-02T10:00:00",
language="en",
breadcrumbs=[
{"name": "Home", "url": "https://padelnomics.io/"},
{"name": "Markets", "url": "https://padelnomics.io/en/markets"},
],
)
def test_always_includes_breadcrumbs(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["Article"], **self._COMMON)
types = [o["@type"] for o in objects]
assert "BreadcrumbList" in types
def test_breadcrumb_positions(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["Article"], **self._COMMON)
bc = [o for o in objects if o["@type"] == "BreadcrumbList"][0]
items = bc["itemListElement"]
assert items[0]["position"] == 1
assert items[0]["name"] == "Home"
assert items[1]["position"] == 2
def test_article_schema(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["Article"], **self._COMMON)
article = [o for o in objects if o["@type"] == "Article"][0]
assert article["headline"] == "Test Title"
assert article["inLanguage"] == "en"
assert article["datePublished"] == "2026-01-01T08:00:00"
assert article["dateModified"] == "2026-01-02T10:00:00"
assert article["publisher"]["name"] == "Padelnomics"
def test_headline_truncated_at_110(self):
from padelnomics.content import build_jsonld
long_title = "A" * 200
objects = build_jsonld(["Article"], **{**self._COMMON, "title": long_title})
article = [o for o in objects if o["@type"] == "Article"][0]
assert len(article["headline"]) == 110
def test_faqpage_schema(self):
from padelnomics.content import build_jsonld
faq_pairs = [
{"question": "How much?", "answer": "About 500k."},
{"question": "How long?", "answer": "12 months."},
]
objects = build_jsonld(["Article", "FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
faq = [o for o in objects if o["@type"] == "FAQPage"][0]
assert len(faq["mainEntity"]) == 2
assert faq["mainEntity"][0]["name"] == "How much?"
assert faq["mainEntity"][0]["acceptedAnswer"]["text"] == "About 500k."
def test_faqpage_omitted_without_pairs(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=[])
types = [o["@type"] for o in objects]
assert "FAQPage" not in types
def test_no_article_when_not_in_types(self):
from padelnomics.content import build_jsonld
faq_pairs = [{"question": "Q?", "answer": "A."}]
objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
types = [o["@type"] for o in objects]
assert "Article" not in types
assert "FAQPage" in types
# ════════════════════════════════════════════════════════════
# Preview article
# ════════════════════════════════════════════════════════════
class TestPreviewArticle:
async def test_preview_returns_rendered_data(self, db, pseo_env):
from padelnomics.content import preview_article
result = await preview_article("test-city", "miami")
assert result["title"] == "Padel in Miami"
assert result["url_path"] == "/en/markets/us/miami"
assert result["meta_description"] == "Padel costs in Miami"
assert "<h1>" in result["html"]
async def test_preview_unknown_row_raises(self, db, pseo_env):
from padelnomics.content import preview_article
with pytest.raises(AssertionError, match="No row found"):
await preview_article("test-city", "nonexistent")
async def test_preview_with_language(self, db, pseo_env):
from padelnomics.content import preview_article
result = await preview_article("test-city", "miami", lang="de")
assert result["url_path"] == "/de/markets/us/miami"
async def test_preview_unknown_template_raises(self, db, pseo_env):
from padelnomics.content import preview_article
with pytest.raises(AssertionError, match="Template not found"):
await preview_article("nonexistent", "miami")
# ════════════════════════════════════════════════════════════
@@ -772,76 +962,114 @@ class TestAdminTemplates:
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")
async def test_template_list_shows_discovered(self, admin_client, pseo_env):
resp = await admin_client.get("/admin/templates")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Test City Analysis" in html
assert "test-city" in html
async def test_template_create(self, admin_client, db):
class TestAdminTemplateDetail:
async def test_detail_shows_config(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Test City Analysis" in html
assert "serving.test_cities" in html
async def test_detail_shows_columns(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city")
html = (await resp.data).decode()
assert "city_slug" in html
assert "VARCHAR" in html
async def test_detail_shows_sample_data(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city")
html = (await resp.data).decode()
assert "Miami" in html
assert "Berlin" in html
async def test_detail_unknown_slug_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/nonexistent")
assert resp.status_code == 302
class TestAdminTemplatePreview:
async def test_preview_renders_article(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city/preview/miami")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Padel in Miami" in html
async def test_preview_bad_key_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city/preview/nonexistent")
assert resp.status_code == 302
async def test_preview_bad_template_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/bad-slug/preview/miami")
assert resp.status_code == 302
class TestAdminTemplateGenerate:
async def test_generate_form(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city/generate")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "3" in html # 3 rows available
assert "Generate" in html
async def test_generate_creates_articles(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/templates/new", form={
resp = await admin_client.post("/admin/templates/test-city/generate", 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 }}",
"start_date": "2026-04-01",
"articles_per_day": "2",
})
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"
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
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')"""
)
scenarios = await fetch_all("SELECT * FROM published_scenarios")
assert len(scenarios) == 3
async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/nonexistent/generate")
assert resp.status_code == 302
class TestAdminTemplateRegenerate:
async def test_regenerate_updates_articles(self, admin_client, db, pseo_env):
from padelnomics.content import generate_articles
# First generate
await generate_articles("test-city", date(2026, 3, 1), 10)
initial = await fetch_all("SELECT * FROM articles")
assert len(initial) == 3
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", form={
resp = await admin_client.post("/admin/templates/test-city/regenerate", 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')"""
)
# Same count — upserted, not duplicated
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", form={
resp = await admin_client.post("/admin/templates/nonexistent/regenerate", 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):
@@ -1012,81 +1240,6 @@ class TestAdminArticles:
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()
# ════════════════════════════════════════════════════════════