test(pseo): add 45 tests for health checks + pSEO Engine 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) and all 6 routes in
admin/pseo_routes.py (dashboard, health partial, gaps partial, generate
gaps, jobs list, job status polling).

Also fixes two bugs found while writing tests:
- check_hreflang_orphans: was grouping by url_path, but EN/DE articles
  have different paths. Now extracts natural key from slug pattern
  "{template_slug}-{lang}-{nk}" and groups by nk.
- pseo_job_status.html + pseo_jobs.html: | default('') | truncate() fails
  when completed_at is None (default() only handles undefined, not None).
  Fixed to (value or '') | truncate().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 20:50:03 +01:00
parent a051f9350f
commit ee49862d91
4 changed files with 804 additions and 20 deletions

View File

@@ -32,8 +32,8 @@
</div>
{% else %}—{% endif %}
</td>
<td class="text-xs text-slate">{{ job.created_at | default('') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ job.completed_at | default('') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ (job.created_at or '') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ (job.completed_at or '') | truncate(19, True, '') }}</td>
<td>
{% if job.error %}
<details>

View File

@@ -75,8 +75,8 @@
</div>
{% else %}—{% endif %}
</td>
<td class="text-xs text-slate">{{ job.created_at | default('') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ job.completed_at | default('') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ (job.created_at or '') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ (job.completed_at or '') | truncate(19, True, '') }}</td>
<td>
{% if job.error %}
<details>

View File

@@ -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