"""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 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 OVERLAPS ...` (binary time-period operator) → syntax error. Здесь mini-check: SQL-литерал не должен содержать запрещённые alias'ы как `WITH 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 AS (`` или ``, 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: ``:::`` где ```` — буквы/_, ```` — буквы (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." )