gendesign/backend/tests/workers/tasks/test_forecast.py
Light1YT c94810f3a7
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m11s
feat(forecast): forecast_site_finder_report Celery task + schema-filtered run read (3b-i)
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.
2026-06-05 09:02:15 +05:00

255 lines
10 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.

"""Тесты 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"