build_site_finder_report (§22) takes ~30-180s → runs in a background Celery task, not inline on the sync /analyze endpoint. - repository: latest_run_for gains keyword-only schema_version (default None keeps v_analysis_runs_latest behavior, backward-compat); when given, reads base analysis_runs filtered by schema_version ORDER BY created_at DESC LIMIT 1 — fetches the latest analyze-1.0 site-analysis run even when newer 1.0 (§22) rows exist on top (index-served via 127's (cad_num, created_at DESC)). - new workers/tasks/forecast.py::forecast_site_finder_report: reads latest analyze-1.0, runs the §22 orchestrator, persists SiteFinderReport.as_dict() as a 1.0 run via persist_analysis_run. Graceful: no base run / compute error → logger + return None (worker not crashed). time_limit=900/soft=840 (no global limit). Registered in include. Prod-confirmed: analyze-1.0 result carries the full analyze dict (competitors+district) → orchestrator input valid. Endpoint trigger (3b-ii) + §9.x untouched. 943 tests pass; code-review APPROVE (contracts verified vs real as_dict(); status done→complete normalized, no IntegrityError). Refs #994 #961.
255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""Тесты Celery-таски forecast_site_finder_report (#3b-i, workers/tasks/forecast.py).
|
||
|
||
Чистые тесты (mock session + mock читателя/оркестратора/persist, без живой БД/§9.x).
|
||
Покрывают:
|
||
• happy path: read latest 'analyze-1.0' → build_site_finder_report → persist с
|
||
schema_version='1.0', status='done', report-dict как result, верными
|
||
confidence/segment/params; возврат нового id;
|
||
• graceful: нет 'analyze-1.0' рана → return None, build НЕ зван, persist НЕ зван,
|
||
без исключения;
|
||
• read зовётся именно с schema_version=ANALYZE_SCHEMA_VERSION (база = site-analysis);
|
||
• horizon домешивается в default-сетку горизонтов;
|
||
• неожиданный сбой compute → return None (таска не валит воркер), db.close вызван.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION
|
||
|
||
|
||
def _fake_analyze_row(
|
||
*,
|
||
result: dict[str, Any] | None = None,
|
||
district: str | None = "Кировский",
|
||
run_id: int = 101,
|
||
) -> MagicMock:
|
||
"""Fake Row последнего 'analyze-1.0' рана (атрибутный доступ row.result/.district/.id)."""
|
||
row = MagicMock(name="analyze-row")
|
||
row.id = run_id
|
||
row.district = district
|
||
row.result = (
|
||
result
|
||
if result is not None
|
||
else {
|
||
"district": {"district_name": "Кировский"},
|
||
"competitors": [{"obj_class": "комфорт"}],
|
||
}
|
||
)
|
||
return row
|
||
|
||
|
||
def _fake_report_dict(
|
||
*,
|
||
schema_version: str = "1.0",
|
||
confidence_level: str | None = "low",
|
||
segment: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Стаб SiteFinderReport.as_dict() — только поля, которые читает таска для persist."""
|
||
return {
|
||
"schema_version": schema_version,
|
||
"advisory": True,
|
||
"confidence": {"level": confidence_level},
|
||
"meta": {"segment": segment if segment is not None else {"obj_class": "комфорт"}},
|
||
"exec_summary": {"headline": "stub"},
|
||
}
|
||
|
||
|
||
def _fake_report(d: dict[str, Any]) -> MagicMock:
|
||
"""Стаб отчёта с as_dict() → d."""
|
||
report = MagicMock(name="report")
|
||
report.as_dict.return_value = d
|
||
return report
|
||
|
||
|
||
# ── happy path ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_task_happy_path_persists_forecast_run() -> None:
|
||
"""Read 'analyze-1.0' → build → persist('1.0', status='done', report-dict, right
|
||
confidence/segment/params); возвращает новый id."""
|
||
from app.workers.tasks import forecast as task_mod
|
||
|
||
db = MagicMock()
|
||
report_dict = _fake_report_dict(confidence_level="low", segment={"obj_class": "комфорт"})
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()) as mock_read,
|
||
patch.object(
|
||
task_mod, "build_site_finder_report", return_value=_fake_report(report_dict)
|
||
) as mock_build,
|
||
patch.object(task_mod, "persist_analysis_run", return_value=777) as mock_persist,
|
||
):
|
||
result = task_mod.forecast_site_finder_report.run("66:41:0000000:1", 12, "tester")
|
||
|
||
assert result == 777
|
||
|
||
# read взят именно по site-analysis схеме
|
||
mock_read.assert_called_once()
|
||
assert mock_read.call_args.kwargs["schema_version"] == ANALYZE_SCHEMA_VERSION
|
||
|
||
# build получил analyze-dict + district + cad_num
|
||
mock_build.assert_called_once()
|
||
bkw = mock_build.call_args.kwargs
|
||
assert bkw["cad_num"] == "66:41:0000000:1"
|
||
assert bkw["district"] == "Кировский"
|
||
assert bkw["analyze"]["competitors"] == [{"obj_class": "комфорт"}]
|
||
assert 12 in bkw["horizons"]
|
||
|
||
# persist получил отчёт-dict как result + контракт §22-рана
|
||
mock_persist.assert_called_once()
|
||
pkw = mock_persist.call_args.kwargs
|
||
assert pkw["cad_num"] == "66:41:0000000:1"
|
||
assert pkw["result"] is report_dict
|
||
assert pkw["schema_version"] == "1.0"
|
||
assert pkw["status"] == "done"
|
||
assert pkw["confidence"] == "low"
|
||
assert pkw["segment"] == {"obj_class": "комфорт"}
|
||
assert pkw["params"] == {"horizon": 12, "source": "forecast-task"}
|
||
assert pkw["district"] == "Кировский"
|
||
assert pkw["created_by"] == "tester"
|
||
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_district_fallback_to_analyze_dict() -> None:
|
||
"""district из row.district = None → fallback в analyze['district']['district_name']."""
|
||
from app.workers.tasks import forecast as task_mod
|
||
|
||
db = MagicMock()
|
||
row = _fake_analyze_row(
|
||
district=None,
|
||
result={"district": {"district_name": "Чкаловский"}, "competitors": []},
|
||
)
|
||
report_dict = _fake_report_dict()
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "latest_run_for", return_value=row),
|
||
patch.object(
|
||
task_mod, "build_site_finder_report", return_value=_fake_report(report_dict)
|
||
) as mock_build,
|
||
patch.object(task_mod, "persist_analysis_run", return_value=5),
|
||
):
|
||
task_mod.forecast_site_finder_report.run("66:41:0000000:2", 12, None)
|
||
|
||
assert mock_build.call_args.kwargs["district"] == "Чкаловский"
|
||
|
||
|
||
def test_task_horizon_merged_into_default_grid() -> None:
|
||
"""Запрошенный horizon (вне default-сетки) домешивается в horizons, отсортировано."""
|
||
from app.workers.tasks import forecast as task_mod
|
||
|
||
db = MagicMock()
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()),
|
||
patch.object(
|
||
task_mod, "build_site_finder_report", return_value=_fake_report(_fake_report_dict())
|
||
) as mock_build,
|
||
patch.object(task_mod, "persist_analysis_run", return_value=1),
|
||
):
|
||
task_mod.forecast_site_finder_report.run("66:41:0000000:1", 9, None)
|
||
|
||
horizons = mock_build.call_args.kwargs["horizons"]
|
||
assert 9 in horizons
|
||
assert horizons == sorted(horizons)
|
||
# default-сетка сохранена
|
||
assert {6, 12, 18, 24}.issubset(set(horizons))
|
||
|
||
|
||
# ── graceful: no base run ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_task_graceful_when_no_analyze_run() -> None:
|
||
"""Нет 'analyze-1.0' рана → return None, build НЕ зван, persist НЕ зван, без исключения."""
|
||
from app.workers.tasks import forecast as task_mod
|
||
|
||
db = MagicMock()
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "latest_run_for", return_value=None) as mock_read,
|
||
patch.object(task_mod, "build_site_finder_report") as mock_build,
|
||
patch.object(task_mod, "persist_analysis_run") as mock_persist,
|
||
):
|
||
result = task_mod.forecast_site_finder_report.run("66:41:0000000:9", 12, None)
|
||
|
||
assert result is None
|
||
mock_read.assert_called_once()
|
||
mock_build.assert_not_called()
|
||
mock_persist.assert_not_called()
|
||
db.close.assert_called_once()
|
||
|
||
|
||
# ── resilience: compute blows up ───────────────────────────────────────────────
|
||
|
||
|
||
def test_task_returns_none_on_compute_error() -> None:
|
||
"""Неожиданный сбой build_site_finder_report → return None (таска не валит воркер),
|
||
persist НЕ зван, db закрыта."""
|
||
from app.workers.tasks import forecast as task_mod
|
||
|
||
db = MagicMock()
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()),
|
||
patch.object(
|
||
task_mod, "build_site_finder_report", side_effect=RuntimeError("§9.x blew up")
|
||
),
|
||
patch.object(task_mod, "persist_analysis_run") as mock_persist,
|
||
):
|
||
result = task_mod.forecast_site_finder_report.run("66:41:0000000:1", 12, None)
|
||
|
||
assert result is None
|
||
mock_persist.assert_not_called()
|
||
db.close.assert_called_once()
|
||
|
||
|
||
def test_task_returns_none_when_persist_returns_none() -> None:
|
||
"""persist best-effort вернул None (write провалился) → таска возвращает None, без краша."""
|
||
from app.workers.tasks import forecast as task_mod
|
||
|
||
db = MagicMock()
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()),
|
||
patch.object(
|
||
task_mod, "build_site_finder_report", return_value=_fake_report(_fake_report_dict())
|
||
),
|
||
patch.object(task_mod, "persist_analysis_run", return_value=None),
|
||
):
|
||
result = task_mod.forecast_site_finder_report.run("66:41:0000000:1", 12, None)
|
||
|
||
assert result is None
|
||
db.close.assert_called_once()
|
||
|
||
|
||
# ── registration / time limit ──────────────────────────────────────────────────
|
||
|
||
|
||
def test_task_registered_in_celery_include() -> None:
|
||
"""Таска зарегистрирована (module в include-списке celery_app)."""
|
||
from app.workers.celery_app import celery_app
|
||
|
||
assert "app.workers.tasks.forecast" in celery_app.conf.include
|
||
|
||
|
||
def test_task_has_generous_time_limit() -> None:
|
||
"""build_site_finder_report идёт минуты → щедрый per-task лимит (time_limit=900)."""
|
||
from app.workers.tasks.forecast import forecast_site_finder_report
|
||
|
||
assert forecast_site_finder_report.time_limit == 900
|
||
assert forecast_site_finder_report.soft_time_limit == 840
|
||
|
||
|
||
def test_task_name_matches_convention() -> None:
|
||
"""Имя таски следует house-style 'tasks.<module>.<func>'."""
|
||
from app.workers.tasks.forecast import forecast_site_finder_report
|
||
|
||
assert forecast_site_finder_report.name == "tasks.forecast.forecast_site_finder_report"
|