gendesign/backend/tests/integration/test_analyze_parcels_sql.py
bot-backend 14818f0303
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864)
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после
#3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый
коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки
на нетронутых строках (длинные assert-сообщения и т.п.).

Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего,
кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих
проектах, повторный `format --check` — 1001 файл без изменений
(идемпотентно), compileall чистый.

Refs #2864
2026-08-21 16:39:28 +05:00

206 lines
11 KiB
Python
Raw 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.

"""Integration-тесты для analyze hot-path SQL — EXPLAIN против реальной prod-PG.
Поймает SQL-уровневые регрессии, которые mock-юнит-тесты (MagicMock на
``db.execute``) НЕ ловят, потому что mock не передаёт SQL планировщику:
1. **PG keyword conflict в CTE** — `WITH overlaps AS (...)` падает с
`syntax error at or near "overlaps"`. Incident #1195→#1197 (#1198):
PR #1195 (2026-06-12) ввёл CTE-alias ``overlaps``, ронявший каждый
``POST /analyze/{cad}`` ~10ч до hotfix #1197.
2. **psycopg v3 `:x::type` antipattern** — `:wkt::text` молча игнорируется
парсером SQLAlchemy 2.0 + psycopg3 → `syntax error at or near ':'`.
Recurring class (см. `.claude/rules/backend.md`,
vault `Bug_SQLAlchemy_DoubleColon_Cast`).
3. **Неверные имена PostGIS-функций** (typo: `ST_Distanse`, `ST_DWithn`,
`ST_Intersect` без `s`) — `function does not exist`.
4. **Несуществующая колонка/таблица** (PR #196 ``geom_3857``, PR #213
``units_count``) — phantom-column-gate класс багов.
Подход: модуль-level SQL-константы (``_NEIGHBORS_SUMMARY_SQL``,
``_IRD_OVERLAP_SQL``) импортируются КАК ЕСТЬ из production-кода → EXPLAIN
с реальными bind-params. EXPLAIN выполняет parse + plan, не выполняет
сами scan'ы → детерминированно, быстро (~50ms на запрос), не зависит от
prod-данных.
Запуск:
# Без TEST_DATABASE_URL — все тесты skip:
uv run pytest tests/integration/ -v
# С SSH-туннелем (`ssh -N gendesign` → localhost:15432):
export TEST_DATABASE_URL="postgresql+psycopg://USER:PASS@localhost:15432/DB"
uv run pytest tests/integration/test_analyze_parcels_sql.py -v -m integration
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL
from tests.integration.conftest import requires_test_db
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
# (без БД, всегда run) и два класса runtime (EXPLAIN, skip без
# TEST_DATABASE_URL). Маркер ``integration`` ставим точечно — только на
# runtime-тесты — иначе compile-time guard'ы тоже skipаются в обычном CI run.
# WKT-полигон в ЕКБ (МСК-66 / EPSG:4326) — небольшой квадрат в центре города.
# Используется только как bind-param: EXPLAIN не сканирует геометрию, не зависит
# от того, есть ли реальные cad_buildings в этих координатах.
_EKB_WKT = (
"POLYGON((60.6000 56.8400, 60.6020 56.8400, 60.6020 56.8420, 60.6000 56.8420, 60.6000 56.8400))"
)
def _explain_text(db: Session, sql: str, params: dict) -> None:
"""EXPLAIN над raw-SQL-строкой с bind-params.
Не EXECUTE — только parse+plan. Бросает SQLAlchemy/psycopg-исключение,
если SQL невалиден (keyword conflict, неверная PostGIS-функция,
несуществующая колонка/таблица, `:x::type` antipattern).
"""
db.execute(text(f"EXPLAIN {sql}"), params)
# ── _neighbors_summary (incident #1195/#1197) ────────────────────────────────
class TestNeighborsSummarySql:
"""``_NEIGHBORS_SUMMARY_SQL`` из ``app.api.v1.parcels``.
Этот тест бы поймал PR #1195 (CTE alias ``overlaps``) на CI вместо прода.
"""
@requires_test_db
@pytest.mark.integration
def test_explain_neighbors_summary(self, phantom_check_session: Session) -> None:
"""SQL парсится и планируется против реальной PG schema + PostGIS."""
# SQL-константа — TextClause. EXPLAIN над TextClause не поддерживается
# SQLAlchemy напрямую (нужен текст для конкатенации с "EXPLAIN ").
# Извлекаем raw SQL через .text — это canonical способ инспектировать
# TextClause-литерал.
raw_sql = _NEIGHBORS_SUMMARY_SQL.text
_explain_text(
phantom_check_session,
raw_sql,
{"wkt": _EKB_WKT, "our_cad": "66:41:0303161:999"},
)
def test_neighbors_cte_aliases_not_pg_keywords(self) -> None:
"""Регрессионный guard: CTE-alias'ы не должны быть PG-keywords.
Историческая ловушка: `WITH overlaps AS (...)` парсится как
`WITH <expr> OVERLAPS <expr> ...` (binary time-period operator) →
syntax error. Здесь mini-check: SQL-литерал не должен содержать
запрещённые alias'ы как `WITH <name> AS (`.
"""
# Set из PG reserved keywords, которые потенциально могут оказаться
# CTE-alias-ом. Не полный список — только те, что встречались как ловушка.
forbidden_aliases = {"overlaps", "user", "current_date", "select", "where"}
raw_sql = _NEIGHBORS_SUMMARY_SQL.text.lower()
for kw in forbidden_aliases:
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
# формы CTE-биндинга.
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
)
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
class TestIrdOverlapSql:
"""``_IRD_OVERLAP_SQL`` из ``app.services.site_finder.ird_overlay_lookup``.
Уже использует canonical ``CAST(:parcel_wkt AS text)`` — этот тест
фиксирует поведение и поймает регрессию если кто-то откатит на
``:parcel_wkt::text``.
"""
@requires_test_db
@pytest.mark.integration
def test_explain_ird_overlap(self, phantom_check_session: Session) -> None:
"""SQL парсится против реальной ``ird_overlays`` schema + PostGIS."""
raw_sql = _IRD_OVERLAP_SQL.text
_explain_text(
phantom_check_session,
raw_sql,
{"parcel_wkt": _EKB_WKT},
)
# ── Cross-module antipattern guard ───────────────────────────────────────────
class TestPsycopg3CastAntipattern:
"""Регрессионный guard: ни одна из проверяемых SQL-констант не должна
содержать ``:name::type`` (psycopg v3 silent ignore).
Это compile-time check (не требует БД) — но живёт здесь, чтобы тестовый
модуль был единым source-of-truth для analyze-SQL invariants.
Reference: `.claude/rules/backend.md`, vault ``Bug_SQLAlchemy_DoubleColon_Cast``.
"""
@pytest.mark.parametrize(
"name,sql",
[
("_NEIGHBORS_SUMMARY_SQL", _NEIGHBORS_SUMMARY_SQL.text),
("_IRD_OVERLAP_SQL", _IRD_OVERLAP_SQL.text),
],
ids=["neighbors_summary", "ird_overlap"],
)
def test_no_bind_double_colon_cast(self, name: str, sql: str) -> None:
"""``:bind::type`` запрещён. Только ``CAST(:bind AS type)``.
Regex pattern: ``:<word>::<type>`` где ``<word>`` — буквы/_,
``<type>`` — буквы (text, int, float, etc).
Исключение из правила (``ARRAY[:rc]::int[]``) здесь не встречается —
проверяем строго ``:name::type``.
"""
import re
matches = re.findall(r":[a-z_]+::[a-z]+", sql)
assert not matches, (
f"{name} содержит psycopg v3 antipattern: {matches}. "
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
)
# ── velocity: конкуренты в радиусе (#2464-G) ─────────────────────────────────
class TestVelocityCompetitorsSql:
"""``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``.
Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G
не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который
существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE
``latest_obj`` (FROM domrf_kn_objects, без алиаса) →
``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08).
Почему это не падало в проде: единственный вызывающий
(``analyze_parcel``) ``obj_class`` не передаёт → ветка мёртвая.
Падало бы молча — исключение глотает ``except`` в ``compute_velocity``,
и блок velocity просто исчезал бы из отчёта с одной строкой в логе.
Тест закрывает обе ветки, а не только ту, что сегодня исполняется.
"""
@requires_test_db
@pytest.mark.integration
@pytest.mark.parametrize(
"class_filter",
["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"],
ids=["no_class_filter", "with_class_filter"],
)
def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None:
"""Обе подстановки шаблона парсятся и планируются против реальной схемы."""
_explain_text(
phantom_check_session,
_COMPETITORS_SQL_TMPL.format(class_filter=class_filter),
{"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"},
)