gendesign/backend/tests/api/v1/test_admin_scrape_freshness.py
Light1YT d38417d8af
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m39s
Deploy / build-worker (push) Successful in 7m20s
Deploy / deploy (push) Successful in 2m48s
feat(admin): freshness endpoint (#73) + README Forgejo refresh (#83) + migrations doc (#72)
#73: GET /admin/scrape/freshness — last_success/age/freshness/status для 5 источников
(kn/objective/nspd/nspd_geo/cadastre) + overall_status (worst по critical). Prod-verified
имена таблиц, FILTER/COALESCE корректны, CAST(:d AS interval). +4 теста.
#83: README — GitHub→Forgejo CI/CD, секция 6 subagents, ссылки перемаплены.
#72: docs/Schema_Migrations_vs_Alembic.md — _schema_migrations паттерн (не Alembic),
рекомендация снести пустой alembic-скелет (отдельный PR).

Closes #73
Closes #83
Closes #72
2026-06-13 18:25:32 +00:00

197 lines
7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для GET /api/v1/admin/scrape/freshness (#73 B5-3).
Эндпоинт читает 5 run-таблиц (kn / objective / nspd / nspd_geo / cadastre) и
для каждой считает свежесть. Здесь DB замокан: на каждый per-source SELECT
возвращаем заранее заданную строку, чтобы проверить классификацию
fresh/stale/critical, маппинг status (ok/stale/failed) и агрегат overall_status
по критичным источникам.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from app.api.v1 import admin_scrape
from app.core.db import get_db
from app.main import app
ENDPOINT = "/api/v1/admin/scrape/freshness"
def _ago(days: float) -> datetime:
return datetime.now(UTC) - timedelta(days=days)
def _make_db(per_source: dict[str, dict[str, Any]]) -> MagicMock:
"""Db-mock: каждый execute()'s SELECT относится к одной run-таблице; матчим по
имени таблицы в SQL и возвращаем соответствующую заранее заданную строку.
per_source: ключ — имя run-таблицы, значение — mapping-строка (как .first()).
"""
db = MagicMock()
def execute(stmt: Any, params: Any = None) -> Any:
sql = str(stmt)
matched: dict[str, Any] | None = None
# Матчим по FROM <table> — самый длинный матч (nspd_geo_jobs vs nspd_*).
for table in sorted(per_source, key=len, reverse=True):
if f"FROM {table}" in sql:
matched = per_source[table]
break
result = MagicMock()
result.mappings.return_value.first.return_value = matched
return result
db.execute = execute
return db
def _override_db(db: MagicMock) -> None:
def _factory():
yield db
app.dependency_overrides[get_db] = _factory
def test_freshness_mixed_statuses() -> None:
"""kn свежий (ok), objective stale, nspd без успехов (failed), nspd_geo critical,
cadastre fresh. overall_status = failed (по критичному nspd_geo→failed)."""
rows = {
"kn_scrape_runs": {
"last_success_at": _ago(1.0), # < fresh_days=2 → fresh → ok
"last_attempt_at": _ago(1.0),
"upd_24h": 1500,
"upd_7d": 1500,
"last_status": "done",
},
"objective_scrape_runs": {
"last_success_at": _ago(10.0), # fresh<7, stale<14 → stale
"last_attempt_at": _ago(10.0),
"upd_24h": 0,
"upd_7d": 325000,
"last_status": "done",
},
"nspd_scrape_runs": {
"last_success_at": None, # нет успехов → failed
"last_attempt_at": _ago(20.0),
"upd_24h": 0,
"upd_7d": 0,
"last_status": "failed",
},
"nspd_geo_jobs": {
"last_success_at": _ago(40.0), # >= stale_days=30 → critical → failed
"last_attempt_at": _ago(40.0),
"upd_24h": 0,
"upd_7d": 0,
"last_status": "done",
},
"cadastre_jobs": {
"last_success_at": _ago(3.0), # < fresh_days=14 → fresh → ok
"last_attempt_at": _ago(3.0),
"upd_24h": 5,
"upd_7d": 5,
"last_status": "done",
},
}
_override_db(_make_db(rows))
try:
resp = TestClient(app).get(ENDPOINT)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
body = resp.json()
assert "generated_at" in body
assert len(body["sources"]) == 5
by_src = {s["source"]: s for s in body["sources"]}
assert by_src["kn"]["status"] == "ok"
assert by_src["kn"]["freshness"] == "fresh"
assert by_src["kn"]["objects_updated_7d"] == 1500
assert by_src["kn"]["critical"] is True
assert by_src["objective"]["status"] == "stale"
assert by_src["objective"]["freshness"] == "stale"
assert by_src["nspd"]["status"] == "failed"
assert by_src["nspd"]["last_success_at"] is None
assert by_src["nspd"]["age_days"] is None
assert by_src["nspd_geo"]["status"] == "failed"
assert by_src["nspd_geo"]["freshness"] == "critical"
assert by_src["cadastre"]["status"] == "ok"
# overall_status — худший по критичным (kn=ok, objective=stale) → stale.
# nspd_geo критичным НЕ помечен, поэтому его failed в overall не попадает.
assert by_src["nspd_geo"]["critical"] is False
assert body["overall_status"] == "stale"
def test_freshness_all_healthy() -> None:
"""Все источники свежие → overall_status=ok."""
fresh_row = {
"last_success_at": _ago(0.5),
"last_attempt_at": _ago(0.5),
"upd_24h": 10,
"upd_7d": 100,
"last_status": "done",
}
rows = {
s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES
}
_override_db(_make_db(rows))
try:
resp = TestClient(app).get(ENDPOINT)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["overall_status"] == "ok"
assert all(s["status"] == "ok" for s in body["sources"])
assert all(s["age_days"] is not None for s in body["sources"])
def test_freshness_critical_source_failed_drives_overall() -> None:
"""Критичный источник (objective) без успехов → overall_status=failed."""
base = {
"last_success_at": _ago(0.5),
"last_attempt_at": _ago(0.5),
"upd_24h": 1,
"upd_7d": 1,
"last_status": "done",
}
rows = {s.table: dict(base) for s in admin_scrape._FRESHNESS_SOURCES}
# objective критичный — обнулим успех.
rows["objective_scrape_runs"] = {
"last_success_at": None,
"last_attempt_at": _ago(5.0),
"upd_24h": 0,
"upd_7d": 0,
"last_status": "running",
}
_override_db(_make_db(rows))
try:
resp = TestClient(app).get(ENDPOINT)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["overall_status"] == "failed"
def test_classify_freshness_boundaries() -> None:
"""Юнит на пороги классификатора: границы fresh/stale/critical, unknown."""
f = admin_scrape._classify_freshness
assert f(None, 2.0, 5.0) == "unknown"
assert f(1.9, 2.0, 5.0) == "fresh"
assert f(2.0, 2.0, 5.0) == "stale" # ровно fresh_days → уже stale
assert f(4.9, 2.0, 5.0) == "stale"
assert f(5.0, 2.0, 5.0) == "critical" # ровно stale_days → critical
assert f(100.0, 2.0, 5.0) == "critical"