feat(parcels): typed AnalyzeResponse + run-history endpoints — EPIC #961 (#992,#994) #1082
5 changed files with 804 additions and 2 deletions
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
397
backend/tests/api/v1/test_run_history_and_response_contract.py
Normal file
397
backend/tests/api/v1/test_run_history_and_response_contract.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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}"
|
||||
Loading…
Add table
Reference in a new issue