gendesign/backend/tests/integration/test_analyze_parcels_sql.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00: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": "комфорт"},
)