test: add E2E and unit tests for pSEO CMS flows
38 new tests covering all previously untested code paths: Content module unit tests: - TestDiscoverTemplates: discovery, empty dir, invalid frontmatter - TestLoadTemplate: config+body loading, missing template, schema normalization - TestExtractFaqPairs: FAQ extraction, no FAQ section, end-of-doc - TestBuildBreadcrumbs: path segments, root, hyphenated labels - TestBuildJsonld: BreadcrumbList, Article, FAQPage, headline truncation - TestPreviewArticle: rendering, unknown row, language, unknown template Admin route E2E tests: - TestAdminTemplateDetail: config view, columns, sample data, unknown slug - TestAdminTemplatePreview: rendered article, bad key/template redirects - TestAdminTemplateGenerate: form display, article+scenario creation, unknown - TestAdminTemplateRegenerate: idempotent update, unknown template redirect - TestAdminTemplates: list shows discovered templates from disk Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -107,6 +107,14 @@ TEST_ROWS = [
|
||||
{"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):
|
||||
@@ -124,6 +132,11 @@ def pseo_env(tmp_path, monkeypatch):
|
||||
(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)
|
||||
@@ -542,6 +555,232 @@ class TestRenderPattern:
|
||||
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")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Public routes
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -723,6 +962,113 @@ class TestAdminTemplates:
|
||||
resp = await admin_client.get("/admin/templates")
|
||||
assert resp.status_code == 200
|
||||
|
||||
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
|
||||
|
||||
|
||||
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/test-city/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
|
||||
|
||||
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("/admin/templates/test-city/regenerate", form={
|
||||
"csrf_token": "test",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
# 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("/admin/templates/nonexistent/regenerate", form={
|
||||
"csrf_token": "test",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
class TestAdminScenarios:
|
||||
|
||||
Reference in New Issue
Block a user