gendesign/backend/tests/workers/test_scrape_cadastre.py
Light1YT f5e7fcdad9
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
fix(workers): cadastre permanent-fail targets_failed + beat cleanup (#1236)
2026-06-13 08:36:13 +00:00

271 lines
11 KiB
Python
Raw Permalink 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.

"""Тесты для backend/app/workers/tasks/scrape_cadastre.py.
Покрывает:
- Fix H — per-quarter freshness skip в enqueue_cadastre_harvest.
- Bug #1236 — генерик-фейл инкрементит targets_failed только на финальной
попытке + регистрирует cleanup_cadastre_zombies в beat_schedule.
"""
from __future__ import annotations
import datetime as _dt
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
def _make_mock_job(quarters: list[str], raw_targets_extra: dict[str, Any] | None = None) -> dict:
"""Mock cadastre_jobs row."""
raw_targets = {"quarters": quarters}
if raw_targets_extra:
raw_targets.update(raw_targets_extra)
return {
"job_id": 99,
"status": "pending",
"raw_targets": raw_targets,
}
def _make_mock_db(job: dict, fresh_quarters: list[str] | None = None) -> MagicMock:
"""Mock DB session.
1. db.execute("SELECT * FROM cadastre_jobs ...").mappings().first() → job
2. db.execute("SELECT cad_number FROM cad_quarter_stats ...").scalars().all() → fresh
3. db.execute(UPDATE) → no-op
"""
db = MagicMock()
fresh_quarters = fresh_quarters or []
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
sql = str(stmt).upper()
result = MagicMock()
if "FROM CADASTRE_JOBS" in sql and "WHERE JOB_ID" in sql and "SELECT *" in str(stmt):
result.mappings.return_value.first.return_value = job
elif "FROM CAD_QUARTER_STATS" in sql:
result.scalars.return_value.all.return_value = fresh_quarters
else:
# UPDATE / other queries
result.scalars.return_value.all.return_value = []
result.mappings.return_value.first.return_value = None
return result
db.execute = execute_router
return db
def test_enqueue_skips_fresh_quarters_with_default_threshold() -> None:
"""Fix H: фреш кварталы (cad_quarter_stats.fetched_at < 1h) пропускаются."""
from app.workers.tasks import scrape_cadastre
quarters = ["66:41:0001001", "66:41:0001002", "66:41:0001003"]
fresh = ["66:41:0001001", "66:41:0001002"] # 2 свежих, 1 stale
job = _make_mock_job(quarters)
db = _make_mock_db(job, fresh_quarters=fresh)
with (
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
patch.object(scrape_cadastre.bulk_harvest_quarter_task, "apply_async") as mock_apply,
):
result = scrape_cadastre.enqueue_cadastre_harvest.run(job_id=99)
# Только 1 stale quarter должен попасть в apply_async
assert mock_apply.call_count == 1
assert mock_apply.call_args[1]["args"] == ["66:41:0001003", 99]
assert result["enqueued"] == 1
assert result["skipped_fresh"] == 2
def test_enqueue_no_skip_when_skip_fresh_hours_zero() -> None:
"""skip_fresh_hours=0 → отключает freshness check, все quarters enqueued."""
from app.workers.tasks import scrape_cadastre
quarters = ["66:41:0001001", "66:41:0001002"]
job = _make_mock_job(quarters, raw_targets_extra={"skip_fresh_hours": 0})
db = _make_mock_db(job, fresh_quarters=["66:41:0001001"]) # один свежий
with (
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
patch.object(scrape_cadastre.bulk_harvest_quarter_task, "apply_async") as mock_apply,
):
result = scrape_cadastre.enqueue_cadastre_harvest.run(job_id=99)
# Все 2 quarters должны быть enqueued (skip отключен)
assert mock_apply.call_count == 2
assert result["enqueued"] == 2
assert result["skipped_fresh"] == 0
def test_enqueue_freshness_threshold_passed_in_sql() -> None:
"""skip_fresh_hours=2 → threshold = NOW() - 2h в SQL query."""
from app.workers.tasks import scrape_cadastre
quarters = ["66:41:0001001"]
captured_params: list[dict[str, Any]] = []
job = _make_mock_job(quarters, raw_targets_extra={"skip_fresh_hours": 2})
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
sql = str(stmt).upper()
result = MagicMock()
if "FROM CADASTRE_JOBS" in sql and "SELECT *" in str(stmt):
result.mappings.return_value.first.return_value = job
elif "FROM CAD_QUARTER_STATS" in sql:
if params:
captured_params.append(params)
result.scalars.return_value.all.return_value = []
else:
result.scalars.return_value.all.return_value = []
return result
db = MagicMock()
db.execute = execute_router
with (
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
patch.object(scrape_cadastre.bulk_harvest_quarter_task, "apply_async"),
):
scrape_cadastre.enqueue_cadastre_harvest.run(job_id=99)
assert len(captured_params) == 1
threshold: _dt.datetime = captured_params[0]["t"]
now = _dt.datetime.now(_dt.UTC)
# Threshold должен быть приблизительно 2h назад (±5s tolerance)
delta = now - threshold
assert _dt.timedelta(hours=1, minutes=59) < delta < _dt.timedelta(hours=2, minutes=1)
# ── Bug #1236: permanent non-WAF fail counter ──────────────────────────────
def _make_counter_db() -> tuple[MagicMock, list[dict[str, Any]]]:
"""Mock db, capturing UPDATE cadastre_jobs param dicts in chronological order."""
captured: list[dict[str, Any]] = []
db = MagicMock()
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
sql = str(stmt).upper()
result = MagicMock()
if "UPDATE CADASTRE_JOBS" in sql:
captured.append({"sql": sql, "params": params or {}})
result.rowcount = 1
elif "SELECT TARGETS_TOTAL" in sql:
# _maybe_finish_job: эмулируем done+failed < total → finish не дёрнется,
# тест проверяет ТОЛЬКО факт вызова + targets_failed_inc.
result.mappings.return_value.first.return_value = {
"targets_total": 10,
"targets_done": 0,
"targets_failed": 1,
"status": "running",
}
else:
result.mappings.return_value.first.return_value = None
result.scalars.return_value.all.return_value = []
return result
db.execute = execute_router
return db, captured
def test_bulk_harvest_quarter_final_fail_increments_targets_failed() -> None:
"""Bug #1236: на финальной (retries == max_retries) попытке non-WAF фейл
инкрементит targets_failed_inc=1 и вызывает _maybe_finish_job."""
from app.workers.tasks import scrape_cadastre
db, captured = _make_counter_db()
# Эмулируем `self` Celery task: финальная попытка autoretry.
self_mock = SimpleNamespace(
max_retries=3,
request=SimpleNamespace(retries=3),
)
finish_called: list[int] = []
def fake_finish(_db: Any, job_id: int) -> None:
finish_called.append(job_id)
with (
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
patch.object(scrape_cadastre, "_maybe_finish_job", side_effect=fake_finish),
patch.object(
scrape_cadastre,
"_is_job_cancelled",
return_value=False,
),
patch(
"app.services.cadastre.bulk_harvest.harvest_quarter",
side_effect=RuntimeError("boom"),
),
):
with pytest.raises(RuntimeError):
# Вызываем underlying function через .run (не apply_async).
scrape_cadastre.bulk_harvest_quarter_task.__wrapped__.__func__(
self_mock, "66:41:0001001", 42
)
# В except-ветке ровно один UPDATE с targets_failed_inc=1 и last_error.
fail_updates = [
c for c in captured if c["params"].get("failed_inc") == 1 and "last_error" in c["params"]
]
assert len(fail_updates) == 1, f"expected single fail-update, got: {captured}"
assert fail_updates[0]["params"]["id"] == 42
assert finish_called == [42], "_maybe_finish_job must be called on final attempt"
def test_bulk_harvest_quarter_intermediate_fail_does_not_increment() -> None:
"""Bug #1236: промежуточная (retries < max_retries) попытка НЕ трогает
targets_failed_inc и НЕ вызывает _maybe_finish_job — Celery re-enqueue'ит."""
from app.workers.tasks import scrape_cadastre
db, captured = _make_counter_db()
self_mock = SimpleNamespace(
max_retries=3,
request=SimpleNamespace(retries=1), # ещё две попытки впереди
)
finish_called: list[int] = []
with (
patch.object(scrape_cadastre, "SessionLocal", return_value=db),
patch.object(
scrape_cadastre,
"_maybe_finish_job",
side_effect=lambda _db, jid: finish_called.append(jid),
),
patch.object(scrape_cadastre, "_is_job_cancelled", return_value=False),
patch(
"app.services.cadastre.bulk_harvest.harvest_quarter",
side_effect=RuntimeError("transient"),
),
):
with pytest.raises(RuntimeError):
scrape_cadastre.bulk_harvest_quarter_task.__wrapped__.__func__(
self_mock, "66:41:0001002", 43
)
fail_updates = [c for c in captured if c["params"].get("failed_inc")]
assert fail_updates == [], "intermediate retry must NOT bump targets_failed"
assert finish_called == [], "intermediate retry must NOT call _maybe_finish_job"
# ── Bug #1236: beat schedule registers cadastre-zombie-cleanup ─────────────
def test_beat_schedule_includes_cadastre_zombie_cleanup() -> None:
"""Bug #1236: cleanup_cadastre_zombies должен быть зарегистрирован в beat,
иначе застрявший в 'running' job никто не переведёт в 'paused'."""
from app.workers import beat_schedule
# build_beat_schedule читает DB → мокаем _build_beat_schedule_from_db,
# чтобы тест не зависел от живого Postgres.
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
schedule = beat_schedule.build_beat_schedule()
assert "cadastre-zombie-cleanup" in schedule, (
f"cadastre-zombie-cleanup отсутствует в beat: {sorted(schedule.keys())}"
)
entry = schedule["cadastre-zombie-cleanup"]
assert entry["task"] == "tasks.cadastre.cleanup_zombies"