gendesign/backend/tests/integration/test_analyze_parcels_sql.py
Light1YT c8000d1089
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m24s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m46s
Deploy / build-worker (push) Successful in 2m55s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m10s
test(parcels): integration EXPLAIN-gate для analyze hot-path SQL (#1198)
PR #1195 ввёл CTE alias `overlaps` (PG keyword для time-period operator)
→ каждый POST /analyze падал с syntax error ~10ч до hotfix PR #1197. Mock-БД
в tests/api/v1/test_*.py не поймала: MagicMock на db.execute() не парсит SQL.

Расширяет существующий phantom-column gate (tests/integration/) на
_neighbors_summary и parcel_ird_overlaps SQL:

1. _NEIGHBORS_SUMMARY_SQL вынесен из inline-text в module-level constant
   в parcels.py (паттерн как в best_layouts/ird_overlay_lookup/krt_lookup).
   Заодно сконвертирован на canonical CAST(:wkt AS text) — backend.md rule.
2. EXPLAIN-тесты через phantom_check_session (SSH-туннель к prod-PG) —
   skip без TEST_DATABASE_URL, run в спец CI job.
3. Compile-time guards (всегда работают, без БД):
   - CTE-alias не должен быть PG-keyword (overlaps/user/current_date/select/where)
   - SQL не должен содержать :bind::type (psycopg v3 antipattern)

Поймал бы:
- #1195 (CTE alias overlaps) — оба EXPLAIN-тест + compile-time keyword guard
- :type recurring class — compile-time parametrized guard
- Phantom column / typo PostGIS function — EXPLAIN parses + plans

Refactor backward-compatible: _neighbors_summary использует ту же константу,
behavior идентичен. 26 unit-тестов parcels analyze продолжают зелёные.

Refs #1198
2026-06-13 09:43:49 +05:00

169 lines
8.5 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 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."
)