diff --git a/web/src/padelnomics/admin/templates/admin/pseo_job_status.html b/web/src/padelnomics/admin/templates/admin/pseo_job_status.html index e039860..e55bd2b 100644 --- a/web/src/padelnomics/admin/templates/admin/pseo_job_status.html +++ b/web/src/padelnomics/admin/templates/admin/pseo_job_status.html @@ -32,8 +32,8 @@ {% else %}—{% endif %} - {{ job.created_at | default('') | truncate(19, True, '') }} - {{ job.completed_at | default('') | truncate(19, True, '') }} + {{ (job.created_at or '') | truncate(19, True, '') }} + {{ (job.completed_at or '') | truncate(19, True, '') }} {% if job.error %}
diff --git a/web/src/padelnomics/admin/templates/admin/pseo_jobs.html b/web/src/padelnomics/admin/templates/admin/pseo_jobs.html index 2cb12d3..b761c5a 100644 --- a/web/src/padelnomics/admin/templates/admin/pseo_jobs.html +++ b/web/src/padelnomics/admin/templates/admin/pseo_jobs.html @@ -75,8 +75,8 @@ {% else %}—{% endif %} - {{ job.created_at | default('') | truncate(19, True, '') }} - {{ job.completed_at | default('') | truncate(19, True, '') }} + {{ (job.created_at or '') | truncate(19, True, '') }} + {{ (job.completed_at or '') | truncate(19, True, '') }} {% if job.error %}
diff --git a/web/src/padelnomics/content/health.py b/web/src/padelnomics/content/health.py index 13a6f34..b5da7fc 100644 --- a/web/src/padelnomics/content/health.py +++ b/web/src/padelnomics/content/health.py @@ -235,10 +235,14 @@ async def check_hreflang_orphans(templates: list[dict]) -> list[dict]: For example: city-cost-de generates EN + DE. If the EN article exists but DE is absent, that article is an hreflang orphan. + Orphan detection is based on the slug pattern "{template_slug}-{lang}-{natural_key}". + Articles are grouped by natural key; if any expected language is missing, the group + is an orphan. + Returns list of dicts: { "template_slug": str, - "url_path": str, + "url_path": str, # url_path of one present article for context "present_languages": list[str], "missing_languages": list[str], } @@ -250,24 +254,39 @@ async def check_hreflang_orphans(templates: list[dict]) -> list[dict]: continue # Single-language template — no orphans possible. rows = await fetch_all( - """SELECT url_path, - GROUP_CONCAT(language) as langs, - COUNT(DISTINCT language) as lang_count - FROM articles - WHERE template_slug = ? AND status = 'published' - GROUP BY url_path - HAVING COUNT(DISTINCT language) < ?""", - (t["slug"], len(expected)), + "SELECT slug, language, url_path FROM articles" + " WHERE template_slug = ? AND status = 'published'", + (t["slug"],), ) + + # Group by natural key extracted from slug pattern: + # "{template_slug}-{lang}-{natural_key}" → strip template prefix, then lang prefix. + slug_prefix = t["slug"] + "-" + by_nk: dict[str, dict] = {} # nk → {"langs": set, "url_path": str} for r in rows: - present = set(r["langs"].split(",")) + slug = r["slug"] + lang = r["language"] + if not slug.startswith(slug_prefix): + continue + rest = slug[len(slug_prefix):] # "{lang}-{natural_key}" + lang_prefix = lang + "-" + if not rest.startswith(lang_prefix): + continue + nk = rest[len(lang_prefix):] + if nk not in by_nk: + by_nk[nk] = {"langs": set(), "url_path": r["url_path"]} + by_nk[nk]["langs"].add(lang) + + for nk, info in by_nk.items(): + present = info["langs"] missing = sorted(expected - present) - orphans.append({ - "template_slug": t["slug"], - "url_path": r["url_path"], - "present_languages": sorted(present), - "missing_languages": missing, - }) + if missing: + orphans.append({ + "template_slug": t["slug"], + "url_path": info["url_path"], + "present_languages": sorted(present), + "missing_languages": missing, + }) return orphans diff --git a/web/tests/test_pseo.py b/web/tests/test_pseo.py new file mode 100644 index 0000000..45627eb --- /dev/null +++ b/web/tests/test_pseo.py @@ -0,0 +1,765 @@ +""" +Tests for the pSEO Engine: health checks, content gaps, freshness, and admin routes. + +Covers: + - content/health.py: get_template_stats, get_template_freshness, get_content_gaps, + check_hreflang_orphans, check_missing_build_files, check_broken_scenario_refs, + get_all_health_issues + - admin/pseo_routes.py: all 6 routes (dashboard, health, gaps, generate, jobs, job status) +""" +import json +from unittest.mock import patch + +import pytest +from padelnomics.content.health import ( + check_broken_scenario_refs, + check_hreflang_orphans, + check_missing_build_files, + get_all_health_issues, + get_content_gaps, + get_template_freshness, + get_template_stats, +) +from padelnomics.core import execute, utcnow_iso + +from padelnomics import core + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +async def admin_client(app, db): + """Authenticated admin test client.""" + now = utcnow_iso() + async with db.execute( + "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", + ("pseo-admin@test.com", "pSEO Admin", now), + ) as cursor: + admin_id = cursor.lastrowid + await db.execute( + "INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,) + ) + await db.commit() + + async with app.test_client() as c: + async with c.session_transaction() as sess: + sess["user_id"] = admin_id + yield c + + +# ── DB helpers ──────────────────────────────────────────────────────────────── + + +async def _insert_article( + slug, + url_path, + status="published", + language="en", + template_slug="city-cost-de", + created_at=None, +): + """Insert a minimal article row and return its id.""" + ts = created_at or utcnow_iso() + return await execute( + """INSERT INTO articles + (url_path, slug, title, meta_description, country, region, + status, published_at, language, template_slug, created_at, updated_at) + VALUES (?, ?, ?, ?, 'DE', 'Europe', ?, ?, ?, ?, ?, ?)""", + ( + url_path, + slug, + f"Title {slug}", + f"Desc {slug}", + status, + ts if status == "published" else None, + language, + template_slug, + ts, + ts, + ), + ) + + +async def _insert_scenario(slug="test-scenario"): + """Insert a minimal published_scenario row.""" + from padelnomics.planner.calculator import calc, validate_state + + state = validate_state({"dblCourts": 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 (?, ?, '', 'TestCity', 'TC', 'indoor', 'rent', '2 double', ?, ?)""", + (slug, f"Scenario {slug}", json.dumps(state), json.dumps(d)), + ) + + +async def _insert_task(status="pending", progress_current=0, progress_total=0): + """Insert a generate_articles task row and return its id.""" + now = utcnow_iso() + async with core._db.execute( + """INSERT INTO tasks + (task_name, payload, status, run_at, progress_current, progress_total, created_at) + VALUES ('generate_articles', '{}', ?, ?, ?, ?, ?)""", + (status, now, progress_current, progress_total, now), + ) as cursor: + task_id = cursor.lastrowid + await core._db.commit() + return task_id + + +# ── DuckDB mock rows ────────────────────────────────────────────────────────── + +_DUCKDB_ROWS = [ + {"city_slug": "berlin", "city": "Berlin", "country": "DE"}, + {"city_slug": "munich", "city": "Munich", "country": "DE"}, + {"city_slug": "hamburg", "city": "Hamburg", "country": "DE"}, +] + + +async def _mock_fetch_duckdb(query, params=None): + return _DUCKDB_ROWS + + +# ════════════════════════════════════════════════════════════════════════════ +# get_template_stats() +# ════════════════════════════════════════════════════════════════════════════ + + +class TestGetTemplateStats: + async def test_empty_db_returns_zeros(self, db): + stats = await get_template_stats("city-cost-de") + assert stats["total"] == 0 + assert stats["published"] == 0 + assert stats["draft"] == 0 + assert stats["by_language"] == {} + + async def test_counts_per_status(self, db): + await _insert_article("city-cost-de-en-berlin", "/en/markets/germany/berlin", + status="published", language="en") + await _insert_article("city-cost-de-en-munich", "/en/markets/germany/munich", + status="draft", language="en") + await _insert_article("city-cost-de-de-berlin", "/de/markets/germany/berlin", + status="published", language="de") + + stats = await get_template_stats("city-cost-de") + + assert stats["total"] == 3 + assert stats["published"] == 2 + assert stats["draft"] == 1 + assert stats["by_language"]["en"]["total"] == 2 + assert stats["by_language"]["de"]["total"] == 1 + + async def test_ignores_other_templates(self, db): + await _insert_article("other-en-berlin", "/en/other/berlin", template_slug="other") + stats = await get_template_stats("city-cost-de") + assert stats["total"] == 0 + + +# ════════════════════════════════════════════════════════════════════════════ +# get_template_freshness() +# ════════════════════════════════════════════════════════════════════════════ + +_SAMPLE_TEMPLATES = [ + { + "slug": "city-cost-de", + "name": "City Cost DE", + "data_table": "serving.pseo_city_costs_de", + "languages": ["en", "de"], + } +] + + +class TestGetTemplateFreshness: + async def test_no_meta_file_returns_no_data(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + monkeypatch.setattr(health_mod, "_read_serving_meta", lambda: {}) + + result = await get_template_freshness(_SAMPLE_TEMPLATES) + assert len(result) == 1 + assert result[0]["status"] == "no_data" + + async def test_meta_present_no_articles_returns_no_articles(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + monkeypatch.setattr(health_mod, "_read_serving_meta", lambda: { + "exported_at_utc": "2026-01-15T10:00:00+00:00", + "tables": {"pseo_city_costs_de": {"row_count": 100}}, + }) + + result = await get_template_freshness(_SAMPLE_TEMPLATES) + assert result[0]["status"] == "no_articles" + assert result[0]["row_count"] == 100 + + async def test_article_older_than_export_returns_stale(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + # Article created Jan 10, data exported Jan 15 → stale + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + status="published", language="en", created_at="2026-01-10T08:00:00", + ) + monkeypatch.setattr(health_mod, "_read_serving_meta", lambda: { + "exported_at_utc": "2026-01-15T10:00:00+00:00", + "tables": {"pseo_city_costs_de": {"row_count": 100}}, + }) + + result = await get_template_freshness(_SAMPLE_TEMPLATES) + assert result[0]["status"] == "stale" + + async def test_article_newer_than_export_returns_fresh(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + # Data exported Jan 10, article updated Jan 15 → fresh + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + status="published", language="en", created_at="2026-01-15T12:00:00", + ) + monkeypatch.setattr(health_mod, "_read_serving_meta", lambda: { + "exported_at_utc": "2026-01-10T10:00:00+00:00", + "tables": {}, + }) + + result = await get_template_freshness(_SAMPLE_TEMPLATES) + assert result[0]["status"] == "fresh" + + +# ════════════════════════════════════════════════════════════════════════════ +# get_content_gaps() +# ════════════════════════════════════════════════════════════════════════════ + + +class TestGetContentGaps: + async def test_no_articles_returns_all_duckdb_rows(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + monkeypatch.setattr(health_mod, "fetch_analytics", _mock_fetch_duckdb) + + gaps = await get_content_gaps( + template_slug="city-cost-de", + data_table="serving.pseo_city_costs_de", + natural_key="city_slug", + languages=["en"], + ) + assert len(gaps) == len(_DUCKDB_ROWS) + assert all(g["_missing_languages"] == ["en"] for g in gaps) + + async def test_existing_article_excluded_from_gaps(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + monkeypatch.setattr(health_mod, "fetch_analytics", _mock_fetch_duckdb) + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", language="en", + ) + + gaps = await get_content_gaps( + template_slug="city-cost-de", + data_table="serving.pseo_city_costs_de", + natural_key="city_slug", + languages=["en"], + ) + gap_keys = {g["_natural_key"] for g in gaps} + assert "berlin" not in gap_keys + assert "munich" in gap_keys + assert "hamburg" in gap_keys + + async def test_partial_language_gap_detected(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + monkeypatch.setattr(health_mod, "fetch_analytics", _mock_fetch_duckdb) + # EN exists for berlin, DE is missing → berlin has a gap for "de" + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", language="en", + ) + + gaps = await get_content_gaps( + template_slug="city-cost-de", + data_table="serving.pseo_city_costs_de", + natural_key="city_slug", + languages=["en", "de"], + ) + berlin = next((g for g in gaps if g["_natural_key"] == "berlin"), None) + assert berlin is not None + assert berlin["_missing_languages"] == ["de"] + + async def test_no_gaps_when_all_articles_exist(self, db, monkeypatch): + import padelnomics.content.health as health_mod + + monkeypatch.setattr(health_mod, "fetch_analytics", _mock_fetch_duckdb) + for key in ("berlin", "munich", "hamburg"): + await _insert_article( + f"city-cost-de-en-{key}", f"/en/markets/germany/{key}", language="en", + ) + + gaps = await get_content_gaps( + template_slug="city-cost-de", + data_table="serving.pseo_city_costs_de", + natural_key="city_slug", + languages=["en"], + ) + assert gaps == [] + + +# ════════════════════════════════════════════════════════════════════════════ +# check_hreflang_orphans() +# ════════════════════════════════════════════════════════════════════════════ + + +class TestCheckHreflangOrphans: + async def test_single_lang_template_no_orphans(self, db): + templates = [{"slug": "city-cost-de", "name": "City Cost DE", "languages": ["en"]}] + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + orphans = await check_hreflang_orphans(templates) + assert orphans == [] + + async def test_bilingual_both_present_no_orphans(self, db): + templates = [{"slug": "city-cost-de", "name": "City Cost DE", "languages": ["en", "de"]}] + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + await _insert_article( + "city-cost-de-de-berlin", "/de/markets/germany/berlin", + language="de", status="published", + ) + orphans = await check_hreflang_orphans(templates) + assert orphans == [] + + async def test_missing_de_sibling_detected(self, db): + templates = [{"slug": "city-cost-de", "name": "City Cost DE", "languages": ["en", "de"]}] + # Only EN for berlin — DE is missing + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + orphans = await check_hreflang_orphans(templates) + assert len(orphans) == 1 + assert orphans[0]["template_slug"] == "city-cost-de" + assert "de" in orphans[0]["missing_languages"] + assert "en" in orphans[0]["present_languages"] + + async def test_draft_articles_not_counted(self, db): + templates = [{"slug": "city-cost-de", "name": "City Cost DE", "languages": ["en", "de"]}] + # Draft articles should be ignored + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="draft", + ) + orphans = await check_hreflang_orphans(templates) + assert orphans == [] + + +# ════════════════════════════════════════════════════════════════════════════ +# check_missing_build_files() +# ════════════════════════════════════════════════════════════════════════════ + + +class TestCheckMissingBuildFiles: + async def test_no_articles_returns_empty(self, db, tmp_path): + result = await check_missing_build_files(build_dir=tmp_path) + assert result == [] + + async def test_build_file_present_not_reported(self, db, tmp_path): + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + build_file = tmp_path / "en" / "city-cost-de-en-berlin.html" + build_file.parent.mkdir(parents=True) + build_file.write_text("

Berlin

") + + result = await check_missing_build_files(build_dir=tmp_path) + assert result == [] + + async def test_missing_build_file_reported(self, db, tmp_path): + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + # No build file created + result = await check_missing_build_files(build_dir=tmp_path) + assert len(result) == 1 + assert result[0]["slug"] == "city-cost-de-en-berlin" + assert result[0]["language"] == "en" + + async def test_draft_articles_ignored(self, db, tmp_path): + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="draft", + ) + result = await check_missing_build_files(build_dir=tmp_path) + assert result == [] + + +# ════════════════════════════════════════════════════════════════════════════ +# check_broken_scenario_refs() +# ════════════════════════════════════════════════════════════════════════════ + + +class TestCheckBrokenScenarioRefs: + async def test_no_markdown_files_returns_empty(self, db, tmp_path): + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + result = await check_broken_scenario_refs(build_dir=tmp_path) + assert result == [] + + async def test_valid_scenario_ref_not_reported(self, db, tmp_path): + await _insert_scenario("berlin-scenario") + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + md_dir = tmp_path / "en" / "md" + md_dir.mkdir(parents=True) + (md_dir / "city-cost-de-en-berlin.md").write_text( + "# Berlin\n\n[scenario:berlin-scenario:capex]\n" + ) + result = await check_broken_scenario_refs(build_dir=tmp_path) + assert result == [] + + async def test_missing_scenario_ref_reported(self, db, tmp_path): + # No scenario in DB, but markdown references one + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + md_dir = tmp_path / "en" / "md" + md_dir.mkdir(parents=True) + (md_dir / "city-cost-de-en-berlin.md").write_text( + "# Berlin\n\n[scenario:ghost-scenario:capex]\n" + ) + result = await check_broken_scenario_refs(build_dir=tmp_path) + assert len(result) == 1 + assert "ghost-scenario" in result[0]["broken_scenario_refs"] + + async def test_no_template_slug_articles_ignored(self, db, tmp_path): + # Legacy article (no template_slug) should not be checked + await execute( + """INSERT INTO articles + (url_path, slug, title, status, language, created_at) + VALUES ('/en/legacy', 'legacy', 'Legacy', 'published', 'en', ?)""", + (utcnow_iso(),), + ) + md_dir = tmp_path / "en" / "md" + md_dir.mkdir(parents=True) + (md_dir / "legacy.md").write_text("# Legacy\n\n[scenario:ghost]\n") + + result = await check_broken_scenario_refs(build_dir=tmp_path) + assert result == [] + + +# ════════════════════════════════════════════════════════════════════════════ +# get_all_health_issues() +# ════════════════════════════════════════════════════════════════════════════ + + +class TestGetAllHealthIssues: + async def test_clean_state_returns_zero_counts(self, db, tmp_path): + templates = [{"slug": "city-cost-de", "name": "City Cost DE", "languages": ["en"]}] + result = await get_all_health_issues(templates, build_dir=tmp_path) + + assert result["counts"]["total"] == 0 + assert result["counts"]["hreflang_orphans"] == 0 + assert result["counts"]["missing_build_files"] == 0 + assert result["counts"]["broken_scenario_refs"] == 0 + assert "hreflang_orphans" in result + assert "missing_build_files" in result + assert "broken_scenario_refs" in result + + async def test_orphan_counted_in_total(self, db, tmp_path): + templates = [{"slug": "city-cost-de", "name": "City Cost DE", "languages": ["en", "de"]}] + # EN article with no DE sibling → orphan + await _insert_article( + "city-cost-de-en-berlin", "/en/markets/germany/berlin", + language="en", status="published", + ) + result = await get_all_health_issues(templates, build_dir=tmp_path) + assert result["counts"]["hreflang_orphans"] == 1 + assert result["counts"]["total"] >= 1 + + +# ════════════════════════════════════════════════════════════════════════════ +# pSEO Route tests +# ════════════════════════════════════════════════════════════════════════════ + +# Mock objects for route tests — avoids needing a live DuckDB +_MOCK_TEMPLATE_CFG = { + "slug": "city-cost-de", + "name": "City Cost DE", + "data_table": "serving.pseo_city_costs_de", + "natural_key": "city_slug", + "languages": ["en", "de"], + "url_pattern": "/markets/{country}/{city_slug}", +} +_MOCK_TEMPLATES = [_MOCK_TEMPLATE_CFG] + + +def _discover_mock(): + return _MOCK_TEMPLATES + + +def _load_template_mock(slug): + if slug == "city-cost-de": + return _MOCK_TEMPLATE_CFG + raise FileNotFoundError(f"Template {slug!r} not found") + + +async def _freshness_mock(templates): + return [ + { + "slug": t["slug"], + "name": t["name"], + "data_table": t["data_table"], + "status": "fresh", + "exported_at_utc": None, + "last_generated": None, + "row_count": 100, + } + for t in templates + ] + + +async def _stats_mock(slug): + return { + "total": 10, "published": 8, "draft": 2, "scheduled": 0, + "by_language": { + "en": {"total": 5, "published": 4, "draft": 1, "scheduled": 0}, + "de": {"total": 5, "published": 4, "draft": 1, "scheduled": 0}, + }, + } + + +async def _health_mock(templates, build_dir=None): + return { + "hreflang_orphans": [], + "missing_build_files": [], + "broken_scenario_refs": [], + "counts": {"hreflang_orphans": 0, "missing_build_files": 0, + "broken_scenario_refs": 0, "total": 0}, + } + + +async def _gaps_empty_mock(template_slug, data_table, natural_key, languages, limit=200): + return [] + + +async def _gaps_two_mock(template_slug, data_table, natural_key, languages, limit=200): + return [ + {"city_slug": "munich", "_natural_key": "munich", "_missing_languages": ["en"]}, + {"city_slug": "hamburg", "_natural_key": "hamburg", "_missing_languages": ["de"]}, + ] + + +class TestPseoRoutes: + """Tests for all pSEO Engine admin blueprint routes.""" + + # -- Access control -------------------------------------------------------- + + async def test_dashboard_requires_admin(self, client, db): + resp = await client.get("/admin/pseo/") + assert resp.status_code in (302, 403) + + async def test_health_requires_admin(self, client, db): + resp = await client.get("/admin/pseo/health") + assert resp.status_code in (302, 403) + + async def test_gaps_requires_admin(self, client, db): + resp = await client.get("/admin/pseo/gaps/city-cost-de") + assert resp.status_code in (302, 403) + + async def test_jobs_requires_admin(self, client, db): + resp = await client.get("/admin/pseo/jobs") + assert resp.status_code in (302, 403) + + # -- Dashboard ------------------------------------------------------------- + + async def test_dashboard_renders(self, admin_client, db): + with ( + patch("padelnomics.admin.pseo_routes.discover_templates", _discover_mock), + patch("padelnomics.admin.pseo_routes.get_template_freshness", _freshness_mock), + patch("padelnomics.admin.pseo_routes.get_template_stats", _stats_mock), + ): + resp = await admin_client.get("/admin/pseo/") + + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "pSEO Engine" in text + + async def test_dashboard_shows_template_name(self, admin_client, db): + with ( + patch("padelnomics.admin.pseo_routes.discover_templates", _discover_mock), + patch("padelnomics.admin.pseo_routes.get_template_freshness", _freshness_mock), + patch("padelnomics.admin.pseo_routes.get_template_stats", _stats_mock), + ): + resp = await admin_client.get("/admin/pseo/") + + text = await resp.get_data(as_text=True) + assert "City Cost DE" in text + + # -- Health HTMX partial --------------------------------------------------- + + async def test_health_partial_renders(self, admin_client, db): + with ( + patch("padelnomics.admin.pseo_routes.discover_templates", _discover_mock), + patch("padelnomics.admin.pseo_routes.get_all_health_issues", _health_mock), + ): + resp = await admin_client.get("/admin/pseo/health") + + assert resp.status_code == 200 + + # -- Content gaps HTMX partial --------------------------------------------- + + async def test_gaps_unknown_template_returns_404(self, admin_client, db): + def _raise(slug): + raise FileNotFoundError("not found") + + with patch("padelnomics.admin.pseo_routes.load_template", _raise): + resp = await admin_client.get("/admin/pseo/gaps/no-such-template") + + assert resp.status_code == 404 + + async def test_gaps_partial_renders(self, admin_client, db): + with ( + patch("padelnomics.admin.pseo_routes.load_template", _load_template_mock), + patch("padelnomics.admin.pseo_routes.get_content_gaps", _gaps_two_mock), + ): + resp = await admin_client.get("/admin/pseo/gaps/city-cost-de") + + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + # Should show gap count or row content + assert "munich" in text or "missing" in text.lower() + + async def test_gaps_empty_shows_no_gaps_message(self, admin_client, db): + with ( + patch("padelnomics.admin.pseo_routes.load_template", _load_template_mock), + patch("padelnomics.admin.pseo_routes.get_content_gaps", _gaps_empty_mock), + ): + resp = await admin_client.get("/admin/pseo/gaps/city-cost-de") + + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "No gaps" in text or "all" in text.lower() + + # -- Generate gaps POST ---------------------------------------------------- + + async def test_generate_gaps_redirects(self, admin_client, db): + async with admin_client.session_transaction() as sess: + sess["csrf_token"] = "test" + + with ( + patch("padelnomics.admin.pseo_routes.load_template", _load_template_mock), + patch("padelnomics.admin.pseo_routes.get_content_gaps", _gaps_two_mock), + ): + resp = await admin_client.post( + "/admin/pseo/gaps/city-cost-de/generate", + form={"csrf_token": "test"}, + ) + + assert resp.status_code == 302 + + async def test_generate_gaps_enqueues_task(self, admin_client, db): + async with admin_client.session_transaction() as sess: + sess["csrf_token"] = "test" + + with ( + patch("padelnomics.admin.pseo_routes.load_template", _load_template_mock), + patch("padelnomics.admin.pseo_routes.get_content_gaps", _gaps_two_mock), + ): + await admin_client.post( + "/admin/pseo/gaps/city-cost-de/generate", + form={"csrf_token": "test"}, + ) + + tasks = await core.fetch_all( + "SELECT task_name FROM tasks WHERE task_name = 'generate_articles'" + ) + assert len(tasks) == 1 + + async def test_generate_gaps_no_gaps_redirects_without_task(self, admin_client, db): + async with admin_client.session_transaction() as sess: + sess["csrf_token"] = "test" + + with ( + patch("padelnomics.admin.pseo_routes.load_template", _load_template_mock), + patch("padelnomics.admin.pseo_routes.get_content_gaps", _gaps_empty_mock), + ): + resp = await admin_client.post( + "/admin/pseo/gaps/city-cost-de/generate", + form={"csrf_token": "test"}, + ) + + assert resp.status_code == 302 + tasks = await core.fetch_all( + "SELECT task_name FROM tasks WHERE task_name = 'generate_articles'" + ) + assert len(tasks) == 0 + + # -- Jobs list ------------------------------------------------------------- + + async def test_jobs_renders_empty(self, admin_client, db): + resp = await admin_client.get("/admin/pseo/jobs") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Generation Jobs" in text + + async def test_jobs_shows_task_row(self, admin_client, db): + await _insert_task(status="complete", progress_current=20, progress_total=20) + + resp = await admin_client.get("/admin/pseo/jobs") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Complete" in text + + # -- Job status HTMX polled ------------------------------------------------ + + async def test_job_status_not_found_returns_404(self, admin_client, db): + resp = await admin_client.get("/admin/pseo/jobs/9999/status") + assert resp.status_code == 404 + + async def test_job_status_renders_pending(self, admin_client, db): + job_id = await _insert_task( + status="pending", progress_current=5, progress_total=20 + ) + + resp = await admin_client.get(f"/admin/pseo/jobs/{job_id}/status") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Running" in text + + async def test_job_status_renders_complete(self, admin_client, db): + job_id = await _insert_task( + status="complete", progress_current=20, progress_total=20 + ) + + resp = await admin_client.get(f"/admin/pseo/jobs/{job_id}/status") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Complete" in text + + async def test_job_status_complete_no_htmx_poll_trigger(self, admin_client, db): + """A completed job should not include hx-trigger="every 2s" (stops HTMX polling).""" + job_id = await _insert_task( + status="complete", progress_current=20, progress_total=20 + ) + + resp = await admin_client.get(f"/admin/pseo/jobs/{job_id}/status") + text = await resp.get_data(as_text=True) + assert "every 2s" not in text + + async def test_job_status_pending_includes_htmx_poll_trigger(self, admin_client, db): + """A pending job should include hx-trigger="every 2s" (keeps HTMX polling).""" + job_id = await _insert_task( + status="pending", progress_current=0, progress_total=20 + ) + + resp = await admin_client.get(f"/admin/pseo/jobs/{job_id}/status") + text = await resp.get_data(as_text=True) + assert "every 2s" in text