From afd46398afe7927a1544b484f825aa5690402195 Mon Sep 17 00:00:00 2001 From: Deeman Date: Mon, 23 Feb 2026 12:48:26 +0100 Subject: [PATCH] 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 --- web/tests/test_content.py | 346 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/web/tests/test_content.py b/web/tests/test_content.py index be9b866..6476212 100644 --- a/web/tests/test_content.py +++ b/web/tests/test_content.py @@ -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 "

" 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: