"""Тесты 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..'.""" from app.workers.tasks.forecast import forecast_site_finder_report assert forecast_site_finder_report.name == "tasks.forecast.forecast_site_finder_report"