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