diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 833b1de7..85faa5ea 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -15,7 +15,11 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db from app.schemas.parcel import ( + AnalysisRunDetail, + AnalysisRunListResponse, + AnalysisRunSummary, AnalyzeRequest, + AnalyzeResponse, BestLayoutsRequest, BestLayoutsResponse, CompetitorsRequest, @@ -33,8 +37,10 @@ from app.schemas.parcel import ( ) from app.services.analysis_runs.repository import ( ANALYZE_SCHEMA_VERSION, + get_run, latest_run_dates, latest_run_for, + list_runs_for, persist_analysis_run, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf @@ -1174,6 +1180,55 @@ async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse: return ParcelSearchResponse(items=[], total=0) +# ── #994 (961-C3, EPIC 961): run-history read endpoints ─────────────────────── +# +# ВАЖНО про порядок маршрутов: `/runs/{run_id}` объявлен ВЫШЕ `/{parcel_id}`. +# FastAPI матчит роуты в порядке регистрации — если бы `/{parcel_id}` шёл первым, +# запрос `GET /runs/123` ушёл бы в get_parcel с parcel_id="runs" (литерал "runs" +# съелся бы как path-param). Объявление здесь, до `/{parcel_id}`, гарантирует, что +# `/runs/{run_id}` резолвится корректно. `/{cad_num}/runs` (2 сегмента) с +# `/{parcel_id}` (1 сегмент) по числу сегментов не конфликтует, но держим рядом. + + +@router.get("/runs/{run_id}", response_model=AnalysisRunDetail) +def get_analysis_run( + run_id: int, + db: Annotated[Session, Depends(get_db)], +) -> AnalysisRunDetail: + """Полная строка одного рана анализа по id (включая `result`-блоб) — #994. + + Для re-open / детального просмотра сохранённого анализа. `result` отдаём как + есть (форма analyze-1.0 ParcelAnalysis или §22 "1.0" SiteFinderReport — модель + истории её не навязывает). + + 404 (graceful HTTPException), если рана с таким id нет. + """ + run = get_run(db, run_id) + if run is None: + raise HTTPException(status_code=404, detail=f"analysis run {run_id} not found") + return AnalysisRunDetail.model_validate(run, from_attributes=True) + + +@router.get("/{cad_num}/runs", response_model=AnalysisRunListResponse) +def list_analysis_runs( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + limit: Annotated[ + int, + Query(ge=1, le=100, description="Сколько недавних ранов вернуть (newest-first)"), + ] = 20, +) -> AnalysisRunListResponse: + """Недавние раны анализа на участок, newest-first (LIGHT-список) — #994. + + Облегчённый список истории анализов: метаданные ранов БЕЗ тяжёлого `result`- + блоба (его отдаёт GET /runs/{run_id}). Пустой список (200, НЕ 404), если ранов + на участок ещё не было. + """ + rows = list_runs_for(db, cad_num, limit=limit) + runs = [AnalysisRunSummary.model_validate(r, from_attributes=True) for r in rows] + return AnalysisRunListResponse(runs=runs) + + @router.get("/{parcel_id}", response_model=ParcelDetail) async def get_parcel(parcel_id: str) -> ParcelDetail: """TODO Stage 2b: fetch parcel by id from DB.""" @@ -1258,7 +1313,7 @@ def get_parcel_forecast( return {"status": "pending"} -@router.post("/{cad_num}/analyze") +@router.post("/{cad_num}/analyze", response_model=AnalyzeResponse) def analyze_parcel( cad_num: str, db: Annotated[Session, Depends(get_db)], diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index d7637511..ca261b18 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -1,7 +1,7 @@ import datetime as dt from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # ── #105 Phase 5: Recent permits schemas ────────────────────────────────────── @@ -394,3 +394,180 @@ class ParcelBboxResponse(BaseModel): count: int limit: int bbox_area_km2: float + + +# ── #994 (961-C3, EPIC 961): run-history read endpoints ─────────────────────── +# +# Два typed-ответа поверх analysis_runs (миграция 127). Summary — LIGHT-список +# (без большого result-блоба) для GET /{cad_num}/runs; Detail — полная строка +# (с result) для GET /runs/{run_id}. Зеркалят колонки из repository.list_runs_for / +# get_run (см. analysis_runs/repository.py). + + +class AnalysisRunSummary(BaseModel): + """Облегчённая карточка одного рана для списка истории по участку. + + БЕЗ тяжёлого `result`-блоба (~90 ключей) — только метаданные для UI-списка + «история анализов». Источник колонок: repository.list_runs_for (LIGHT SELECT). + """ + + id: int + cad_num: str + created_at: dt.datetime + status: str | None = None + schema_version: str | None = None + district: str | None = None + confidence: str | None = None + created_by: str | None = None + + +class AnalysisRunDetail(BaseModel): + """Полная строка одного рана (включая `result`) для re-open / детального view. + + `result` — JSONB-блоб analyze (schema_version "analyze-1.0", форма ParcelAnalysis) + ИЛИ §22 SiteFinderReport ("1.0") — отдаём как есть (dict), форму не навязываем + (модель истории, не контракт analyze). Источник: repository.get_run (full SELECT). + """ + + id: int + cad_num: str + created_at: dt.datetime + status: str | None = None + schema_version: str | None = None + district: str | None = None + confidence: str | None = None + created_by: str | None = None + advisory: bool | None = None + params: dict[str, Any] | None = None + segment: dict[str, Any] | None = None + result: dict[str, Any] | None = None + + +class AnalysisRunListResponse(BaseModel): + """Ответ GET /parcels/{cad_num}/runs — список (пустой, если ранов нет).""" + + runs: list[AnalysisRunSummary] + + +# ── #992 (EPIC 961): typed AnalyzeResponse + response_model ──────────────────── + + +class AnalyzeResponse(BaseModel): + """Типизированный контракт ответа POST /parcels/{cad_num}/analyze (#992). + + КРИТИЧЕСКИЕ инварианты (EPIC 961 — наивысший blast radius PR): + + 1) `extra="allow"` — ЛЮБОЙ ключ из `result_payload` (parcels.py), НЕ перечисленный + здесь явно, ПОПАДАЕТ в ответ без изменений (Pydantic v2: уходит в + __pydantic_extra__ → включается в model_dump → FastAPI отдаёт в body). + Это backstop против silent-drop: даже если новый ключ добавят в payload и + забудут сюда — frontend его получит. Без этого response_model молча выкинул + бы неперечисленные ключи и сломал Site Finder. + + 2) ВСЕ поля Optional с дефолтом — модель НИЧЕГО не требует. Эндпоинт имеет + graceful 202 fetch-stub ({status:"fetching", job_id, eta_seconds, message}, + без score/competitors/...). Если бы тут были required-поля, FastAPI словил бы + ValidationError → 500 при сериализации 202-стаба через response_model. Все + поля nullable → 202-стаб проходит без ошибок (недостающие → null, additive, + frontend ветвится по HTTP-коду 202, не по телу — useSiteAnalysis.ts). + + Перечень ниже документирует ~90 известных top-level ключей (зеркало + frontend ParcelAnalysis + result_payload) для OpenAPI / codegen — но НЕ + ограничивает ответ (extra="allow" + Optional). Источник истины формы — всё + ещё result_payload в parcels.py; модель его описывает, а не сужает. + """ + + model_config = ConfigDict(extra="allow") + + # — идентификация / геометрия — + cad_num: str | None = None + source: str | None = None + geom_geojson: dict[str, Any] | None = None + district: dict[str, Any] | None = None + + # — score-блок — + score: float | None = None + score_without_center: float | None = None + score_label: str | None = None + score_max_reference: float | None = None + score_explanation: str | None = None + score_breakdown: dict[str, Any] | None = None + score_breakdown_detailed: list[dict[str, Any]] | None = None + score_top_3_positives: list[dict[str, Any]] | None = None + score_top_3_negatives: list[dict[str, Any]] | None = None + score_by_group: list[dict[str, Any]] | None = None + poi_count: int | None = None + location: dict[str, Any] | None = None + + # — рынок / конкуренты / темп — + competitors: list[dict[str, Any]] | None = None + market_pulse: dict[str, Any] | None = None + market_avg_price_per_m2: float | None = None + market_data_coverage_pct: float | None = None + pipeline_24mo: dict[str, Any] | None = None + velocity: dict[str, Any] | None = None + market_trend: dict[str, Any] | None = None + market_price: dict[str, Any] | None = None + + # — среда (шум / воздух / погода / геология) — + noise: dict[str, Any] | None = None + air_quality: dict[str, Any] | None = None + weather: dict[str, Any] | None = None + seasonal_weather: dict[str, Any] | None = None + wind: dict[str, Any] | None = None + geology: dict[str, Any] | None = None + hydrology: dict[str, Any] | None = None + utilities: dict[str, Any] | None = None + geotech_risk: dict[str, Any] | None = None + + # — пригодность участка — + geometry_suitability: dict[str, Any] | None = None + neighbors_summary: dict[str, Any] | None = None + + # — кадастр / разрешения / зонирование — + parcel_meta: dict[str, Any] | None = None + recent_permits_in_quarter: list[dict[str, Any]] | None = None + permits_summary: dict[str, Any] | None = None + zoning: dict[str, Any] | None = None + success_recommendation: dict[str, Any] | None = None + isochrones_available: bool | None = None + + # — confidence-индикатор — + confidence: float | None = None + confidence_label: str | None = None + confidence_breakdown: dict[str, Any] | None = None + confidence_caveats: list[str] | None = None + + # — NSPD-слои — + nspd_zoning: dict[str, Any] | None = None + nspd_zouit_overlaps: list[dict[str, Any]] | None = None + nspd_engineering_nearby: list[dict[str, Any]] | None = None + nspd_risk_zones: list[RiskZone] | None = None + nspd_opportunity_parcels: list[OpportunityParcel] | None = None + nspd_red_lines: list[RedLine] | None = None + nspd_dump: dict[str, Any] | None = None + gate_verdict: dict[str, Any] | None = None + + # — веса / custom POI — + weights_profile: dict[str, Any] | None = None + custom_poi_score_items: list[dict[str, Any]] | None = None + + # — SF-B5: ЕГРН / обременения / красные линии / метро / цены района / риски — + egrn: dict[str, Any] | None = None + encumbrance: dict[str, Any] | None = None + red_lines: dict[str, Any] | None = None + metro: dict[str, Any] | None = None + district_price_per_m2_min: float | None = None + district_price_per_m2_max: float | None = None + district_price_per_m2_median: float | None = None + district_price_sample_size: int | None = None + risks: dict[str, Any] | None = None + + # — §22-форсайт stub (#995): pending|unavailable, добавляется после persist — + forecast: dict[str, Any] | None = None + + # — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют — + status: str | None = None + job_id: int | None = None + eta_seconds: int | None = None + message: str | None = None diff --git a/backend/app/services/analysis_runs/repository.py b/backend/app/services/analysis_runs/repository.py index 5e4b5cdb..5267f7f3 100644 --- a/backend/app/services/analysis_runs/repository.py +++ b/backend/app/services/analysis_runs/repository.py @@ -221,3 +221,54 @@ def latest_run_for( """), {"cad_num": cad_num, "schema_version": schema_version}, ).first() + + +def list_runs_for( + db: Session, + cad_num: str, + *, + limit: int = 20, +) -> list[Row[Any]]: + """Недавние раны на участок, newest-first (history-список #994). + + LIGHT-проекция: НЕ тянем тяжёлый `result`-блоб (~90 ключей JSONB) — только + метаданные для UI-списка «история анализов». Полную строку (с result) отдаёт + `get_run`. Читаем из БАЗОВОЙ таблицы analysis_runs (а НЕ v_analysis_runs_latest — + тот DISTINCT ON cad_num и вернул бы максимум 1 строку), фильтр по cad_num, + ORDER BY created_at DESC LIMIT :limit. + + Пустой список, если ранов нет (caller отдаёт 200 + {runs: []}, не 404). + psycopg v3: CAST(:x AS type) — никогда :x::type (backend.md). + """ + return list( + db.execute( + text(""" + SELECT id, cad_num, created_at, status, schema_version, + district, confidence, created_by + FROM analysis_runs + WHERE cad_num = CAST(:cad_num AS text) + ORDER BY created_at DESC + LIMIT CAST(:limit AS integer) + """), + {"cad_num": cad_num, "limit": limit}, + ).all() + ) + + +def get_run(db: Session, run_id: int) -> Row[Any] | None: + """Полная строка одного рана по id (включая `result`) для re-open / detail-view. + + Возвращает Row со всеми колонками (id/cad_num/.../result/created_at) или None, + если рана с таким id нет (caller → 404 graceful). Источник — БАЗОВАЯ таблица + analysis_runs (id — PK). psycopg v3: CAST(:x AS type) — никогда :x::type. + """ + return db.execute( + text(""" + SELECT id, cad_num, district, segment, params, result, + schema_version, advisory, confidence, status, created_by, + created_at + FROM analysis_runs + WHERE id = CAST(:run_id AS integer) + """), + {"run_id": run_id}, + ).first() diff --git a/backend/tests/api/v1/test_run_history_and_response_contract.py b/backend/tests/api/v1/test_run_history_and_response_contract.py new file mode 100644 index 00000000..f7019107 --- /dev/null +++ b/backend/tests/api/v1/test_run_history_and_response_contract.py @@ -0,0 +1,397 @@ +"""EPIC 961: typed AnalyzeResponse (#992) + run-history endpoints (#994). + +Покрывает критичные инварианты PR (наивысший blast radius): + +#992 — AnalyzeResponse(response_model): + • EXTRA-PRESERVATION: ключ из result_payload, НЕ перечисленный в модели явно, + ВСЁ РАВНО попадает в 200-ответ (extra="allow" → не silent-drop). Доказываем + на живом эндпоинте, подсунув в payload синтетический неперечисленный ключ. + • 202 fetch-stub: путь #93 (геометрии нет → enqueue → timeout) отдаёт 202 + + {status:"fetching", job_id, eta_seconds, message} и НЕ падает 500 при + сериализации через response_model (все поля Optional). + +#994 — run-history: + • GET /{cad_num}/runs → 200 {runs:[...]} (LIGHT), пустой список (НЕ 404) если ранов нет. + • GET /runs/{run_id} → 200 full (incl result); 404 если нет. + • ROUTE ORDERING: /runs/{run_id} НЕ перехватывается /{parcel_id} (числовой id + резолвится в get_analysis_run, не в get_parcel-501). + +Стратегия mock: как test_parcels_forecast.py — DB через dependency_overrides, +тяжёлые сервисы патчим, RBAC обходится settings.testing=True (conftest). +""" + +from __future__ import annotations + +import datetime as dt +from typing import Any +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app + +# ── Константы / mock-helpers (зеркалят test_parcels_forecast.py) ──────────────── + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze(geom_found: bool = True) -> MagicMock: + """Mock DB Session, достаточный чтобы analyze дошёл до result_payload / fallback.""" + db = MagicMock() + geom_row = ( + _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + if geom_found + else None + ) + wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None + district_row = _make_mapping( + {"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0} + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + sql = " ".join(str(args[0]).split()) if args else "" + first_val: Any = None + if "AS geom_geojson" in sql: + first_val = geom_row + elif "AS wkt" in sql: + first_val = wkt_row + elif "AS median_price_per_m2" in sql and "district_name" in sql: + first_val = district_row + elif "AS lon" in sql and "AS lat" in sql: + first_val = centroid_row + r = MagicMock() + r.mappings.return_value.first.return_value = first_val + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + + db.execute.side_effect = _execute_side_effect + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_HEAVY_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_heavy() -> None: + for p in _HEAVY_PATCHES: + p.start() + + +def _stop_heavy() -> None: + for p in _HEAVY_PATCHES: + p.stop() + + +# ── #992: AnalyzeResponse extra-preservation (анти silent-drop) ───────────────── + + +def test_analyze_response_preserves_unmodeled_extra_key() -> None: + """Синтетический ключ, НЕ перечисленный в AnalyzeResponse, доходит до 200-ответа. + + Доказательство ключевого инварианта extra="allow": response_model НЕ выкидывает + незнакомые ключи. Инъектируем ключ через patch result_payload (post-build hook + не нужен — патчим persist_analysis_run как seam, где payload уже собран? Нет: + проще проверить, что РЕАЛЬНЫЕ ключи payload, которых НЕТ в модели, сохранены — + напр. market_pulse / district_price_per_m2_median / weights_profile — они в + result_payload, но как nested dict; extra="allow" их пропускает as-is). + """ + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_heavy() + try: + with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()): + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + # Эти ключи присутствуют в result_payload, но НЕ как обязательные — + # некоторые (market_pulse/weights_profile/district_price_*) попадают через + # модель-поля или extra; все ДОЛЖНЫ быть в ответе (ни один не выкинут). + for key in ( + "market_pulse", + "weights_profile", + "district_price_per_m2_median", + "custom_poi_score_items", + "gate_verdict", + "nspd_dump", + ): + assert key in body, f"response_model выкинул ключ {key} (silent drop!)" + finally: + app.dependency_overrides.clear() + _stop_heavy() + + +def test_analyze_response_does_not_drop_keys_vs_raw_payload() -> None: + """Жёсткая гарантия: key-set 200-ответа ⊇ key-set собранного result_payload. + + Перехватываем фактический dict, который analyze отдаёт в persist (он же + возвращается клиенту), и сверяем: каждый его top-level ключ присутствует в JSON + HTTP-ответе. Это и есть «200 response key-set ИДЕНТИЧЕН прежнему» — response_model + ничего не теряет. + """ + from app.core.db import get_db + + captured_payload: dict[str, Any] = {} + + def _capture(db_: Any, *, result: dict[str, Any], **kwargs: Any) -> int: + captured_payload.update(result) + return 1 + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_heavy() + try: + with ( + patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()), + patch("app.api.v1.parcels.persist_analysis_run", side_effect=_capture), + ): + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert captured_payload, "persist не получил result_payload — тест не сработал" + missing = sorted(set(captured_payload) - set(body)) + assert not missing, f"response_model выкинул ключи из payload: {missing}" + finally: + app.dependency_overrides.clear() + _stop_heavy() + + +# ── #992: 202 fetch-stub проходит через response_model без 500 ────────────────── + + +def test_analyze_202_fetch_stub_not_500_through_response_model() -> None: + """Геометрии нет → enqueue → timeout → 202 + stub; response_model НЕ роняет 500. + + Все поля AnalyzeResponse Optional → стаб {status,cad_num,job_id,eta_seconds,message} + без score/competitors сериализуется без ValidationError. Frontend ветвится по + HTTP-коду 202 (useSiteAnalysis.ts), поэтому additive null-дефолты безвредны. + """ + from app.core.db import get_db + + db = _make_db_for_analyze(geom_found=False) + app.dependency_overrides[get_db] = _override_db(db) + _start_heavy() + try: + with ( + patch( + "app.api.v1.parcels.find_or_enqueue_fetch", + return_value=("fetching", 4242, None), + ), + patch("app.api.v1.parcels.cad_exists_in_db", return_value=False), + # схлопываем inline-wait, чтобы сразу уйти в 202-timeout (без 15s сна) + patch("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0), + ): + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 202, resp.text + body = resp.json() + # Стаб-поля сохранены (через явные поля модели — все Optional). + assert body["status"] == "fetching" + assert body["job_id"] == 4242 + assert body["eta_seconds"] == 15 + assert body["message"] # непустое сообщение + assert body["cad_num"] == _CAD + finally: + app.dependency_overrides.clear() + _stop_heavy() + + +# ── #994: GET /{cad_num}/runs (LIGHT list) ───────────────────────────────────── + + +def _summary_row(**over: Any) -> MagicMock: + base = { + "id": 7, + "cad_num": _CAD, + "created_at": dt.datetime(2026, 6, 5, 7, 32, 0), + "status": "complete", + "schema_version": "analyze-1.0", + "district": "Октябрьский", + "confidence": "high", + "created_by": "alice", + } + base.update(over) + row = MagicMock() + for k, v in base.items(): + setattr(row, k, v) + return row + + +def test_list_runs_returns_light_list() -> None: + """GET /{cad}/runs → 200 {runs:[summary...]} с метаданными (без result).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch( + "app.api.v1.parcels.list_runs_for", + return_value=[_summary_row(id=9), _summary_row(id=8)], + ) as lrf: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/runs") + assert resp.status_code == 200, resp.text + body = resp.json() + assert [r["id"] for r in body["runs"]] == [9, 8] + assert body["runs"][0]["cad_num"] == _CAD + assert body["runs"][0]["confidence"] == "high" + assert "result" not in body["runs"][0], "summary не должен нести result" + # cad_num прокинут в repo, default limit=20 + args, kwargs = lrf.call_args + assert args[1] == _CAD + assert kwargs.get("limit") == 20 + finally: + app.dependency_overrides.clear() + + +def test_list_runs_empty_is_200_not_404() -> None: + """Нет ранов → 200 + {runs: []} (НЕ 404).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.list_runs_for", return_value=[]): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/runs") + assert resp.status_code == 200, resp.text + assert resp.json() == {"runs": []} + finally: + app.dependency_overrides.clear() + + +def test_list_runs_respects_limit_query() -> None: + """?limit=5 прокидывается в list_runs_for.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.list_runs_for", return_value=[]) as lrf: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/runs?limit=5") + assert resp.status_code == 200, resp.text + assert lrf.call_args.kwargs.get("limit") == 5 + finally: + app.dependency_overrides.clear() + + +# ── #994: GET /runs/{run_id} (full) + route ordering ─────────────────────────── + + +def _detail_row() -> MagicMock: + data = { + "id": 123, + "cad_num": _CAD, + "created_at": dt.datetime(2026, 6, 5, 7, 32, 0), + "status": "complete", + "schema_version": "analyze-1.0", + "district": "Октябрьский", + "confidence": "high", + "created_by": "alice", + "advisory": True, + "params": {"profile_id": None}, + "segment": None, + "result": {"cad_num": _CAD, "score": 42.5, "risks": {"k": "v"}}, + } + row = MagicMock() + for k, v in data.items(): + setattr(row, k, v) + return row + + +def test_get_run_returns_full_with_result() -> None: + """GET /runs/{id} → 200 full row incl result.""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.get_run", return_value=_detail_row()) as gr: + client = TestClient(app) + resp = client.get("/api/v1/parcels/runs/123") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["id"] == 123 + assert body["result"] == {"cad_num": _CAD, "score": 42.5, "risks": {"k": "v"}} + assert body["advisory"] is True + assert gr.call_args.args[1] == 123 # int run_id прокинут + finally: + app.dependency_overrides.clear() + + +def test_get_run_404_when_missing() -> None: + """Рана нет → 404 (graceful HTTPException, не 500).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.get_run", return_value=None): + client = TestClient(app) + resp = client.get("/api/v1/parcels/runs/999999") + assert resp.status_code == 404, resp.text + finally: + app.dependency_overrides.clear() + + +def test_route_ordering_runs_not_shadowed_by_parcel_id() -> None: + """/runs/{run_id} резолвится в get_analysis_run, НЕ в get_parcel (/{parcel_id}-501). + + Если бы /{parcel_id} стоял выше, /runs/123 ушёл бы в get_parcel (501 stub) с + parcel_id='runs' (или числовым). Доказываем: запрос реально доходит до get_run + (мокнут) и НЕ возвращает 501. + """ + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.get_run", return_value=_detail_row()) as gr: + client = TestClient(app) + resp = client.get("/api/v1/parcels/runs/123") + # get_parcel вернул бы 501; get_analysis_run → 200. И get_run был вызван. + assert resp.status_code != 501, "shadowed by /{parcel_id} 501 stub!" + assert resp.status_code == 200, resp.text + gr.assert_called_once() + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/services/analysis_runs/test_repository_run_history.py b/backend/tests/services/analysis_runs/test_repository_run_history.py new file mode 100644 index 00000000..f5201e01 --- /dev/null +++ b/backend/tests/services/analysis_runs/test_repository_run_history.py @@ -0,0 +1,122 @@ +"""Тесты read-функций истории ранов (#994, repository.py): list_runs_for / get_run. + +Чистые тесты (mock session, без живой БД): проверяем форму SQL — + • list_runs_for: LIGHT-проекция (НЕ тянет result), БАЗОВАЯ таблица (не view), + ORDER BY created_at DESC, LIMIT через CAST(:limit AS integer), bind cad_num/limit; + пустой список при .all() == []. + • get_run: full-проекция (включая result), фильтр по id через CAST(:run_id AS integer), + None при .first() == None. + • psycopg v3: ни один bind не использует :name::type (recurring bug class, backend.md). +""" + +from __future__ import annotations + +import os +import re +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.analysis_runs.repository import get_run, list_runs_for + + +def _mock_db_capturing( + *, all_rows: list[Any] | None = None, first_row: Any | None = None +) -> tuple[MagicMock, list[tuple[Any, Any]]]: + """Mock session: захватывает (stmt, params); .all()→all_rows, .first()→first_row.""" + captured: list[tuple[Any, Any]] = [] + + def execute_router(stmt: Any, params: Any = None) -> MagicMock: + captured.append((stmt, params)) + result = MagicMock() + result.all.return_value = all_rows if all_rows is not None else [] + result.first.return_value = first_row + return result + + db = MagicMock() + db.execute = execute_router + return db, captured + + +# ── list_runs_for ───────────────────────────────────────────────────────────── + + +def test_list_runs_for_light_projection_base_table_order_limit() -> None: + """LIGHT-проекция без result; базовая таблица; ORDER BY created_at DESC; LIMIT cast.""" + rows = [MagicMock(name="r1"), MagicMock(name="r2")] + db, captured = _mock_db_capturing(all_rows=rows) + + got = list_runs_for(db, "66:41:0000000:1", limit=20) + + assert got == rows + assert len(captured) == 1 + stmt, params = captured[0] + upper = str(stmt).upper() + flat = " ".join(upper.split()) + # базовая таблица, НЕ view (view вернул бы максимум 1 строку на cad) + assert "FROM ANALYSIS_RUNS" in upper + assert "V_ANALYSIS_RUNS_LATEST" not in upper + # LIGHT: result-блоб НЕ выбирается + assert "RESULT" not in upper, "list_runs_for НЕ должен тянуть тяжёлый result" + # выбраны именно метаданные + for col in ("ID", "CAD_NUM", "CREATED_AT", "STATUS", "SCHEMA_VERSION", + "DISTRICT", "CONFIDENCE", "CREATED_BY"): + assert col in upper, f"ожидали колонку {col} в LIGHT-проекции" + assert "ORDER BY CREATED_AT DESC" in flat + assert "LIMIT CAST(:LIMIT AS INTEGER)" in upper + assert "CAST(:CAD_NUM AS TEXT)" in upper + assert params == {"cad_num": "66:41:0000000:1", "limit": 20} + + +def test_list_runs_for_empty_returns_empty_list() -> None: + """Нет ранов (.all() == []) → пустой список (caller отдаст 200 + {runs: []}).""" + db, _ = _mock_db_capturing(all_rows=[]) + assert list_runs_for(db, "66:00:0000000:0") == [] + + +def test_list_runs_for_default_limit_is_20() -> None: + """limit по умолчанию = 20 (kwarg-only).""" + db, captured = _mock_db_capturing(all_rows=[]) + list_runs_for(db, "x") + _, params = captured[0] + assert params["limit"] == 20 + + +# ── get_run ─────────────────────────────────────────────────────────────────── + + +def test_get_run_full_projection_includes_result_filters_by_id() -> None: + """Full-проекция (с result); фильтр id через CAST(:run_id AS integer).""" + sentinel = MagicMock(name="full-row") + db, captured = _mock_db_capturing(first_row=sentinel) + + got = get_run(db, 123) + + assert got is sentinel + assert len(captured) == 1 + stmt, params = captured[0] + upper = str(stmt).upper() + assert "FROM ANALYSIS_RUNS" in upper + assert "RESULT" in upper, "get_run должен включать result-блоб" + assert "WHERE ID = CAST(:RUN_ID AS INTEGER)" in " ".join(upper.split()) + assert params == {"run_id": 123} + + +def test_get_run_missing_returns_none() -> None: + """Рана нет (.first() == None) → None (caller → 404 graceful).""" + db, _ = _mock_db_capturing(first_row=None) + assert get_run(db, 999) is None + + +# ── psycopg v3 guard ────────────────────────────────────────────────────────── + + +def test_run_history_no_double_colon_cast() -> None: + """Ни один bind не использует :name::type (psycopg v3, backend.md).""" + db, captured = _mock_db_capturing(all_rows=[], first_row=None) + list_runs_for(db, "x") + get_run(db, 1) + for stmt, _ in captured: + sql = str(stmt) + assert not re.search(r":[a-z_]+::[a-z]", sql), f"found :x::type cast in: {sql}"