Celery task tasks.supply_layers_refresh computes L1/L2/L3 supply layers per Sverdlovsk district via supply_layers.compute_all_layers and UPSERTs into supply_layers (m.125) on uq_supply_layers_logical, updating all mutable cols (units/area/expected_online_date/confidence/method/computed_at), never the key. Coherent single date.today() snapshot_date across the batch; per-row SAVEPOINT (begin_nested) so a bad row/district can't abort the weekly batch; per-district guard. District set is data-driven (UNION of objective_lots.district and domrf_kn_objects.district_name region_cd=66), not hardcoded. Registered weekly Mon 06:00 МСК (after scrape_kn 04:15 that refreshes free_flats, the L2 source). Adds the task module to celery_app include (beat resolves by name). 11 unit tests asserting the ON CONFLICT contract + savepoint + coherent snapshot.
342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""Тесты Celery-таски supply_layers_refresh (#950 EPIC 6 Step5).
|
||
|
||
Чистые тесты (mock session + mock сервиса, без живой сети/БД). Покрывают:
|
||
• контракт UPSERT-SQL: точный ON CONFLICT target (uq_supply_layers_logical column
|
||
list), DO UPDATE по ВСЕМ мутабельным колонкам (database-expert: иначе stale),
|
||
ключевые колонки НЕ обновляются, CAST(:x AS type) не :x::type (psycopg v3);
|
||
• district-enumeration: UNION objective_lots + domrf_kn_objects (region_cd=66),
|
||
DISTINCT non-NULL, форма запроса;
|
||
• coherent single snapshot_date: один date.today() прокинут КАЖДОЙ строке;
|
||
• per-row SAVEPOINT (db.begin_nested) на каждой строке;
|
||
• счётчики upserted/skipped; resilience: сбой compute одного района не валит батч.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import date
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, call, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.site_finder.supply_layers import SupplyLayerRow
|
||
|
||
|
||
def _make_mock_db(
|
||
district_rows: list[dict[str, Any]],
|
||
) -> tuple[MagicMock, list[dict[str, Any]], list[Any]]:
|
||
"""Mock DB session с поддержкой db.begin_nested() (SAVEPOINT).
|
||
|
||
Первый db.execute (без params, либо с region_cd) трактуем как districts-запрос →
|
||
возвращаем district_rows через .mappings().all(). Остальные execute (UPSERT) —
|
||
захватываем params в captured_upserts.
|
||
|
||
Returns: (db, captured_upserts, executed_sql) — captured_upserts содержит params
|
||
каждого UPSERT-execute; executed_sql — все stmt-объекты (для проверки SQL).
|
||
"""
|
||
captured_upserts: list[dict[str, Any]] = []
|
||
executed_sql: list[Any] = []
|
||
state = {"districts_served": False}
|
||
|
||
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
|
||
executed_sql.append(stmt)
|
||
result = MagicMock()
|
||
# districts-запрос: первый execute (region_cd-only params или до UPSERT'ов)
|
||
if not state["districts_served"] and "supply_layers" not in str(stmt).lower():
|
||
state["districts_served"] = True
|
||
result.mappings.return_value.all.return_value = district_rows
|
||
return result
|
||
if params is not None:
|
||
captured_upserts.append(params)
|
||
return result
|
||
|
||
db = MagicMock()
|
||
db.execute = execute_router
|
||
db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||
db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||
return db, captured_upserts, executed_sql
|
||
|
||
|
||
def _row(
|
||
*,
|
||
layer: int,
|
||
district: str,
|
||
source: str,
|
||
confidence: str = "high",
|
||
units: int | None = 10,
|
||
area: float | None = 500.0,
|
||
obj_class: str | None = None,
|
||
expected: date | None = None,
|
||
snapshot_date: date | None = None,
|
||
) -> SupplyLayerRow:
|
||
return SupplyLayerRow(
|
||
layer=layer, # type: ignore[arg-type]
|
||
district_name=district,
|
||
complex_id=None,
|
||
obj_class=obj_class,
|
||
units_estimate=units,
|
||
area_estimate=area,
|
||
expected_online_date=expected,
|
||
source=source, # type: ignore[arg-type]
|
||
confidence=confidence, # type: ignore[arg-type]
|
||
method="test",
|
||
snapshot_date=snapshot_date,
|
||
)
|
||
|
||
|
||
# ── UPSERT-SQL контракт ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_upsert_sql_conflict_target_and_updated_columns() -> None:
|
||
"""ON CONFLICT таргетит точный логический ключ; DO UPDATE обновляет ВСЕ
|
||
мутабельные колонки + computed_at; ключевые колонки НЕ в SET; нет :x::type."""
|
||
from app.workers.tasks.supply_layers_refresh import _UPSERT_SQL
|
||
|
||
sql = str(_UPSERT_SQL)
|
||
upper = sql.upper()
|
||
|
||
assert "INSERT INTO SUPPLY_LAYERS" in upper
|
||
# точный ON CONFLICT target = uq_supply_layers_logical column-list
|
||
assert (
|
||
"ON CONFLICT (LAYER, DISTRICT_NAME, COMPLEX_ID, OBJ_CLASS, SOURCE, SNAPSHOT_DATE)" in upper
|
||
)
|
||
assert "DO UPDATE SET" in upper
|
||
# ВСЕ мутабельные колонки обновляются (database-expert: иначе stale-данные)
|
||
for col in (
|
||
"UNITS_ESTIMATE = EXCLUDED.UNITS_ESTIMATE",
|
||
"AREA_ESTIMATE = EXCLUDED.AREA_ESTIMATE",
|
||
"EXPECTED_ONLINE_DATE = EXCLUDED.EXPECTED_ONLINE_DATE",
|
||
"CONFIDENCE = EXCLUDED.CONFIDENCE",
|
||
"METHOD = EXCLUDED.METHOD",
|
||
"COMPUTED_AT = NOW()",
|
||
):
|
||
assert col in upper, f"missing updated column: {col}"
|
||
# ключевые колонки НЕ обновляются в SET-части (после DO UPDATE SET)
|
||
set_clause = upper.split("DO UPDATE SET", 1)[1]
|
||
for key_col in (
|
||
"LAYER = EXCLUDED",
|
||
"DISTRICT_NAME = EXCLUDED",
|
||
"COMPLEX_ID = EXCLUDED",
|
||
"OBJ_CLASS = EXCLUDED",
|
||
"SOURCE = EXCLUDED",
|
||
"SNAPSHOT_DATE = EXCLUDED",
|
||
):
|
||
assert key_col not in set_clause, f"key column must not be updated: {key_col}"
|
||
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
|
||
assert "::" not in sql
|
||
assert "CAST(:LAYER AS SMALLINT)" in upper
|
||
assert "CAST(:SNAPSHOT_DATE AS DATE)" in upper
|
||
|
||
|
||
def test_no_double_colon_cast_in_sql() -> None:
|
||
"""psycopg v3: ни один bind не использует :name::type (recurring bug class)."""
|
||
import re
|
||
|
||
from app.workers.tasks.supply_layers_refresh import _DISTRICTS_SQL, _UPSERT_SQL
|
||
|
||
for stmt in (_UPSERT_SQL, _DISTRICTS_SQL):
|
||
sql = str(stmt)
|
||
assert not re.search(r":[a-z_]+::[a-z]", sql), f"found :x::type cast in: {sql}"
|
||
|
||
|
||
# ── district enumeration ─────────────────────────────────────────────────────
|
||
|
||
|
||
def test_districts_query_unions_both_sources() -> None:
|
||
"""Запрос районов = UNION objective_lots.district + domrf_kn_objects.district_name
|
||
(region_cd=66), DISTINCT non-NULL — не хардкодит имена."""
|
||
from app.workers.tasks.supply_layers_refresh import _DISTRICTS_SQL
|
||
|
||
sql = str(_DISTRICTS_SQL)
|
||
upper = sql.upper()
|
||
|
||
assert "FROM OBJECTIVE_LOTS" in upper
|
||
assert "FROM DOMRF_KN_OBJECTS" in upper
|
||
assert "UNION" in upper
|
||
assert "DISTINCT" in upper
|
||
assert "IS NOT NULL" in upper
|
||
# region-фильтр domrf через CAST(:x AS integer)
|
||
assert "REGION_CD = CAST(:REGION_CD AS INTEGER)" in upper
|
||
|
||
|
||
def test_list_districts_filters_null_and_returns_names() -> None:
|
||
"""_list_districts отдаёт только non-NULL имена; на пустом источнике → []."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
db, _, _ = _make_mock_db(
|
||
[{"district_name": "Кировский"}, {"district_name": None}, {"district_name": "Чкаловский"}]
|
||
)
|
||
assert task_mod._list_districts(db) == ["Кировский", "Чкаловский"]
|
||
|
||
db_empty, _, _ = _make_mock_db([])
|
||
assert task_mod._list_districts(db_empty) == []
|
||
|
||
|
||
def test_list_districts_graceful_on_query_error() -> None:
|
||
"""Сбой districts-запроса → [] (батч no-op, не падает)."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("db down")
|
||
assert task_mod._list_districts(db) == []
|
||
|
||
|
||
# ── per-row UPSERT / SAVEPOINT ───────────────────────────────────────────────
|
||
|
||
|
||
def test_upsert_rows_uses_savepoint_per_row() -> None:
|
||
"""Каждая строка UPSERT-ится внутри db.begin_nested() (SAVEPOINT, backend.md)."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
db, captured, _ = _make_mock_db([])
|
||
# пометим districts как обслуженные, чтобы execute шёл в UPSERT-ветку
|
||
db.execute("SELECT FROM supply_layers warmup", None)
|
||
snap = date(2026, 6, 3)
|
||
rows = [
|
||
_row(layer=1, district="Кировский", source="objective", snapshot_date=snap),
|
||
_row(layer=2, district="Кировский", source="domrf_declared", snapshot_date=snap),
|
||
]
|
||
upserted, skipped = task_mod._upsert_rows(db, rows)
|
||
|
||
assert upserted == 2
|
||
assert skipped == 0
|
||
# begin_nested вызван по разу на строку
|
||
assert db.begin_nested.call_count == 2
|
||
# params каждого UPSERT — это row.as_dict()
|
||
assert captured == [rows[0].as_dict(), rows[1].as_dict()]
|
||
|
||
|
||
def test_upsert_rows_savepoint_isolates_bad_row() -> None:
|
||
"""Битая строка (execute бросает) → skipped++, остальные продолжают (SAVEPOINT)."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
captured: list[dict[str, Any]] = []
|
||
calls = {"n": 0}
|
||
|
||
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
|
||
calls["n"] += 1
|
||
if calls["n"] == 2: # вторая строка — битая
|
||
raise RuntimeError("CHECK violation")
|
||
captured.append(params)
|
||
return MagicMock()
|
||
|
||
db = MagicMock()
|
||
db.execute = execute_router
|
||
db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
|
||
db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
|
||
|
||
rows = [
|
||
_row(layer=1, district="A", source="objective", snapshot_date=date(2026, 6, 3)),
|
||
_row(layer=2, district="B", source="domrf_declared", snapshot_date=date(2026, 6, 3)),
|
||
_row(layer=3, district="C", source="domrf_multiphase", snapshot_date=date(2026, 6, 3)),
|
||
]
|
||
upserted, skipped = task_mod._upsert_rows(db, rows)
|
||
|
||
assert upserted == 2
|
||
assert skipped == 1
|
||
# первая и третья строки прошли, вторая — нет
|
||
assert [p["district_name"] for p in captured] == ["A", "C"]
|
||
|
||
|
||
# ── end-to-end task: coherent snapshot, counters, resilience ─────────────────
|
||
|
||
|
||
def test_task_coherent_single_snapshot_date() -> None:
|
||
"""run_date = date.today() ОДИН раз → передан snapshot_date КАЖДОЙ строке всех
|
||
районов; ВСЕ UPSERT-params несут один и тот же snapshot_date."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
db, captured, _ = _make_mock_db(
|
||
[{"district_name": "Кировский"}, {"district_name": "Чкаловский"}]
|
||
)
|
||
today = date(2026, 6, 3)
|
||
|
||
def fake_compute(_db: Any, *, district: str, snapshot_date: date) -> list[SupplyLayerRow]:
|
||
# сервис обязан получить наш единый snapshot_date
|
||
assert snapshot_date == today
|
||
return [_row(layer=1, district=district, source="objective", snapshot_date=snapshot_date)]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "date") as mock_date,
|
||
patch.object(task_mod, "compute_all_layers", side_effect=fake_compute) as mock_compute,
|
||
):
|
||
mock_date.today.return_value = today
|
||
result = task_mod.supply_layers_refresh.run()
|
||
|
||
# compute вызван на каждый район с единым snapshot_date
|
||
assert mock_compute.call_count == 2
|
||
for c in mock_compute.call_args_list:
|
||
assert c == call(db, district=c.kwargs["district"], snapshot_date=today)
|
||
# все UPSERT-params несут один snapshot_date
|
||
assert captured, "expected at least one upsert"
|
||
assert {p["snapshot_date"] for p in captured} == {today}
|
||
assert result["snapshot_date"] == today.isoformat()
|
||
assert result["districts"] == 2
|
||
assert result["rows"] == 2
|
||
assert result["upserted"] == 2
|
||
assert result["skipped"] == 0
|
||
db.commit.assert_called_once()
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_resilient_to_one_district_failure() -> None:
|
||
"""Сбой compute_all_layers одного района не валит батч: остальные районы
|
||
обрабатываются, commit вызывается."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
db, captured, _ = _make_mock_db([{"district_name": "Good"}, {"district_name": "Bad"}])
|
||
|
||
def fake_compute(_db: Any, *, district: str, snapshot_date: date) -> list[SupplyLayerRow]:
|
||
if district == "Bad":
|
||
raise RuntimeError("service blew up")
|
||
return [_row(layer=1, district=district, source="objective", snapshot_date=snapshot_date)]
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "compute_all_layers", side_effect=fake_compute),
|
||
):
|
||
result = task_mod.supply_layers_refresh.run()
|
||
|
||
# только "Good" дал строку; "Bad" проглочен (logged) и пропущен
|
||
assert result["districts"] == 2
|
||
assert result["rows"] == 1
|
||
assert result["upserted"] == 1
|
||
assert [p["district_name"] for p in captured] == ["Good"]
|
||
db.commit.assert_called_once()
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_closes_db_on_infra_error() -> None:
|
||
"""Инфраструктурный сбой (напр. districts-enum) пробрасывается, db закрывается."""
|
||
from app.workers.tasks import supply_layers_refresh as task_mod
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = None
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "_list_districts", side_effect=RuntimeError("boom")),
|
||
):
|
||
try:
|
||
task_mod.supply_layers_refresh.run()
|
||
raise AssertionError("expected RuntimeError to propagate")
|
||
except RuntimeError as e:
|
||
assert "boom" in str(e)
|
||
|
||
db.close.assert_called_once()
|
||
|
||
|
||
# ── beat registration ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_task_registered_in_beat_weekly_monday() -> None:
|
||
"""Задача зарегистрирована в beat как еженедельная (понедельник)."""
|
||
from app.workers.beat_schedule import build_beat_schedule
|
||
|
||
schedule = build_beat_schedule()
|
||
assert "supply-layers-refresh-weekly" in schedule
|
||
entry = schedule["supply-layers-refresh-weekly"]
|
||
assert entry["task"] == "tasks.supply_layers_refresh.supply_layers_refresh"
|
||
# crontab day_of_week = понедельник (1)
|
||
assert 1 in entry["schedule"].day_of_week
|