gendesign/backend/tests/workers/test_nspd_geo.py
Light1YT b00e338667
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m38s
Deploy / deploy (push) Successful in 1m12s
fix(nspd_geo): split zombie thresholds running=6m / paused=30m (#1215)
В WAF-ветке `nspd_geo` worker спит до 240с (4 мин при consecutive_waf=8),
heartbeat коммитится ДО сна. Старый `cleanup_zombies` (`* * * * *`) с
порогом `INTERVAL '2 minutes'` для status IN ('running', 'paused')
гарантированно ре-enqueue'ил **живой** WAF-job:
- Два worker'а параллельно долбили забаненный NSPD-сервис, углубляя бан.
- SELECT pending без `FOR UPDATE SKIP LOCKED` → дубли запросов.
- Counter'ы done/failed двух инстансов затираются друг другом.

Бонус-баг: status='paused' (после 8 WAF подряд) воскресал через минуту,
аннулируя WAF-защиту.

Patch: разделил пороги через module-level константы и параметризованный
SQL (CAST(:x AS interval) per psycopg v3 rule):
- `_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"` — выше max WAF backoff (4 мин).
- `_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"` — реальная WAF-пауза.

Beat-расписание (1 мин tick) не тронуто. WAF-логика / heartbeat-in-loop
(вариант B) / FOR UPDATE SKIP LOCKED (вариант «идеальный») вне scope.

4 новых юнит-теста (test_nspd_geo.py): psycopg v3 guard, оба порога
биндятся, два предиката вместо одного, paused > running.
18/18 nspd_geo тестов зелёные. ruff clean.

Closes #1215
2026-06-13 05:37:08 +00:00

416 lines
17 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.

"""Тесты для backend/app/workers/tasks/nspd_geo.py — on-demand НСПД-ингест.
Покрывает 3 prod-confirmed бага «пользователь вводит новый/произвольный реальный участок»:
- Finding 1: _save_parcel / _save_quarter должны ST_Multi() geom (колонки MultiPolygon,
migration 93 / 58) — иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
- Finding 2: _save_building пишет в широкую схему cad_buildings (migration 92):
quarter_cad_number (не quarter_cad_num), source NOT NULL, floors через _safe_int.
Mock DB через unittest.mock — без реального PostgreSQL / NSPD сети.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.workers.tasks import nspd_geo as nspd_geo_mod
from app.workers.tasks.nspd_geo import (
_ZOMBIE_PAUSED_THRESHOLD,
_ZOMBIE_RUNNING_THRESHOLD,
_save_building,
_save_parcel,
_save_quarter,
cleanup_zombies,
)
# ── Фикстуры payload (форма ответа _persist_target: {"data": {"features": [...]}}) ──
def _parcel_payload(cad_num: str = "66:41:0610029:83") -> dict[str, Any]:
"""NSPD-ответ для ЗУ (одноконтурный Polygon — триггер бага type-mismatch)."""
return {
"data": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[60.6, 56.8],
[60.61, 56.8],
[60.61, 56.81],
[60.6, 56.81],
[60.6, 56.8],
]
],
},
"properties": {"options": {"cad_num": cad_num}},
}
]
}
}
def _quarter_payload(cad_num: str = "66:41:0610029") -> dict[str, Any]:
"""NSPD-ответ для квартала (одноконтурный Polygon в EPSG:3857)."""
return {
"data": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090200.0, 7590000.0],
[6090200.0, 7590200.0],
[6090000.0, 7590200.0],
[6090000.0, 7590000.0],
]
],
},
"properties": {"options": {}},
}
]
}
}
def _building_payload(
cad_num: str = "66:41:0610029:83",
floors: Any = "5-7",
quarter: str | None = "66:41:0610029",
) -> dict[str, Any]:
"""NSPD-ответ для здания. floors по умолчанию range-строка "5-7" (триггер бага floors INT)."""
opts: dict[str, Any] = {
"cad_num": cad_num,
"purpose": "Жилое",
"building_name": "Тестовое здание",
"readable_address": "г Екатеринбург, ул Тестовая, д 1",
"area": "1234.56",
"floors": floors,
}
if quarter is not None:
opts["quarter_cad_number"] = quarter
return {
"data": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090050.0, 7590000.0],
[6090050.0, 7590050.0],
[6090000.0, 7590050.0],
[6090000.0, 7590000.0],
]
],
},
"properties": {"options": opts},
}
]
}
}
def _capturing_db() -> tuple[MagicMock, list[tuple[str, Any]]]:
"""Mock Session, фиксирующий (sql_text, params) каждого db.execute."""
captured: list[tuple[str, Any]] = []
db = MagicMock()
db.execute = lambda stmt, params=None: captured.append((str(stmt), params)) or MagicMock()
return db, captured
def _strip_sql_comments(sql: str) -> str:
"""Убрать `-- ...` комментарии — чтобы assert'ить ИСПОЛНЯЕМЫЙ SQL, не текст комментов.
Комментарии легитимно упоминают ST_Transform/ST_Multi, поэтому проверяем выражения
только в реальном SQL.
"""
lines = []
for line in sql.splitlines():
idx = line.find("--")
lines.append(line if idx == -1 else line[:idx])
return "\n".join(lines)
# ── Finding 1: ST_Multi для MultiPolygon-колонок ─────────────────────────────
def test_save_parcel_sql_wraps_geom_in_st_multi() -> None:
"""cad_parcels.geom = MultiPolygon (migration 93) → SQL должен ST_Multi() обернуть geom.
Иначе одноконтурный Polygon из rosreestr2coord → "Geometry type (Polygon) does not
match column type (MultiPolygon)" → on-demand ингест участка крашится.
"""
db, captured = _capturing_db()
n = _save_parcel(db, _parcel_payload(), "66:41:0610029:83")
assert n == 1
assert len(captured) == 1
sql, _params = captured[0]
assert "ST_Multi(" in sql, "SQL _save_parcel должен содержать ST_Multi для MultiPolygon schema"
def test_save_parcel_sql_no_transform_parcel_is_4326() -> None:
"""parcel GeoJSON уже WGS84 (rosreestr2coord) — только ST_SetSRID(...,4326), без ST_Transform.
ST_Transform 3857→4326 на 4326-геометрии схлопнул бы участок в точку у (0,0).
"""
db, captured = _capturing_db()
_save_parcel(db, _parcel_payload(), "66:41:0610029:83")
sql, _params = captured[0]
executable = _strip_sql_comments(sql)
assert "ST_Transform" not in executable, "parcel уже 4326 — ST_Transform схлопнет в точку"
assert "ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)" in executable
def test_save_quarter_sql_wraps_geom_in_st_multi() -> None:
"""cad_quarters_geom.geom = MultiPolygon NOT NULL (migration 58) → ST_Multi() обязателен."""
db, captured = _capturing_db()
n = _save_quarter(db, _quarter_payload(), "66:41:0610029")
assert n == 1
assert len(captured) == 1
sql, _params = captured[0]
assert "ST_Multi(" in sql, "SQL _save_quarter должен содержать ST_Multi для MultiPolygon schema"
def test_save_quarter_sql_keeps_3857_to_4326_transform() -> None:
"""quarter GeoJSON в Mercator (3857) → ST_Transform к 4326 сохраняется (mirror bulk path)."""
db, captured = _capturing_db()
_save_quarter(db, _quarter_payload(), "66:41:0610029")
sql, _params = captured[0]
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
def test_save_parcel_empty_features_returns_zero() -> None:
"""Пустой features → 0, без db.execute."""
db, captured = _capturing_db()
assert _save_parcel(db, {"data": {"features": []}}, "66:41:0610029:83") == 0
assert captured == []
# ── Finding 2: _save_building → широкая схема cad_buildings (migration 92) ─────
def test_save_building_uses_quarter_cad_number_column() -> None:
"""migration 92: колонка quarter_cad_number (НЕ quarter_cad_num) → иначе UndefinedColumn."""
db, captured = _capturing_db()
n = _save_building(db, _building_payload(), "66:41:0610029:83")
assert n == 1
sql, params = captured[0]
assert "quarter_cad_number" in sql
# старое имя колонки не должно встречаться (как отдельное слово quarter_cad_num)
assert "quarter_cad_num " not in sql
assert "quarter_cad_num," not in sql
assert "quarter_cad_num=" not in sql
assert params["qcad"] == "66:41:0610029"
def test_save_building_sets_source_not_null() -> None:
"""cad_buildings.source NOT NULL без дефолта (migration 92) → INSERT должен биндить source."""
db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83")
sql, params = captured[0]
assert ":source" in sql
assert params["source"] == "nspd"
def test_save_building_safe_int_floors_range_string() -> None:
"""floors INT: NSPD range "5-7" → _safe_int → None (иначе InvalidTextRepresentation).
Сырой "5-7" в floors INT крашил бы INSERT.
"""
db, captured = _capturing_db()
_save_building(db, _building_payload(floors="5-7"), "66:41:0610029:83")
_sql, params = captured[0]
assert params["floors"] is None # "5-7" не парсится → None, не сырая строка
def test_save_building_safe_int_floors_numeric() -> None:
"""floors "9" → int 9 (валидное число парсится)."""
db, captured = _capturing_db()
_save_building(db, _building_payload(floors="9"), "66:41:0610029:83")
_sql, params = captured[0]
assert params["floors"] == 9
def test_save_building_derives_quarter_when_missing() -> None:
"""Если NSPD не дал quarter_cad_number → выводим из cad_num (первые 3 сегмента)."""
db, captured = _capturing_db()
_save_building(db, _building_payload(quarter=None), "66:41:0610029:83")
_sql, params = captured[0]
assert params["qcad"] == "66:41:0610029"
def test_save_building_geom_transform_no_st_multi() -> None:
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен,
transform 3857→4326 сохраняется (mirror upsert_building)."""
db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83")
sql, _params = captured[0]
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
assert "ST_Multi(" not in sql # полиморфная колонка — coerce не нужен
def test_save_building_skips_feature_with_other_cad_num() -> None:
"""NSPD может вернуть здания всего квартала — берём только запрошенный cad_num."""
db, captured = _capturing_db()
payload = _building_payload(cad_num="66:41:0610029:99") # чужой cad_num
n = _save_building(db, payload, "66:41:0610029:83")
assert n == 0
assert captured == []
def test_save_building_area_safe_numeric() -> None:
"""area NUMERIC: строковое "1234.56" → float через _safe_numeric."""
db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83")
_sql, params = captured[0]
assert params["area"] == 1234.56
# ── psycopg v3 CAST-trap guard (backend.md) ─────────────────────────────────
def test_save_sql_uses_cast_not_double_colon() -> None:
"""SQL _save_* использует CAST(:x AS jsonb), не :x::jsonb (SQLAlchemy psycopg v3 trap)."""
db, captured = _capturing_db()
_save_parcel(db, _parcel_payload(), "66:41:0610029:83")
_save_quarter(db, _quarter_payload(), "66:41:0610029")
_save_building(db, _building_payload(), "66:41:0610029:83")
for sql, _params in captured:
assert "::jsonb" not in sql, f"SQL содержит ::jsonb — нужен CAST(:x AS jsonb): {sql[:80]}"
assert "CAST(:props AS jsonb)" in sql
# ── cleanup_zombies thresholds (issue #1215: WAF backoff vs zombie race) ────
class _FakeRowsResult:
"""sqlalchemy-like Result, который запоминает (sql, params) и возвращает 0 rows.
cleanup_zombies использует cain `.mappings().all()` — мокаем под него.
"""
def __init__(self, captured: list[tuple[str, dict[str, Any]]], stmt: Any, params: Any) -> None:
captured.append((str(stmt), params or {}))
self._stmt = stmt
self._params = params
def mappings(self) -> _FakeRowsResult:
return self
def all(self) -> list[dict[str, Any]]:
return []
def _capturing_session_factory() -> tuple[MagicMock, list[tuple[str, dict[str, Any]]]]:
"""Fake SessionLocal() — возвращает session, чьи execute-calls фиксируются."""
captured: list[tuple[str, dict[str, Any]]] = []
session = MagicMock()
session.execute = lambda stmt, params=None: _FakeRowsResult(captured, stmt, params)
session.commit = MagicMock()
session.rollback = MagicMock()
session.close = MagicMock()
factory = MagicMock(return_value=session)
return factory, captured
def test_cleanup_zombies_sql_uses_cast_as_interval_not_double_colon(
monkeypatch: Any,
) -> None:
"""psycopg v3: только CAST(:x AS interval), запрещён `:x::interval`."""
factory, captured = _capturing_session_factory()
monkeypatch.setattr(nspd_geo_mod, "SessionLocal", factory)
cleanup_zombies()
assert len(captured) == 1, "cleanup_zombies должен выполнить ровно один UPDATE"
sql, _params = captured[0]
assert "CAST(:running_threshold AS interval)" in sql
assert "CAST(:paused_threshold AS interval)" in sql
# SQLAlchemy + psycopg v3 ломаются на `:name::type` (backend.md)
assert ":running_threshold::" not in sql
assert ":paused_threshold::" not in sql
def test_cleanup_zombies_binds_running_and_paused_thresholds(monkeypatch: Any) -> None:
"""Issue #1215: running порог > max WAF backoff (240s = 4 min), paused — большой cooldown.
Контракт thresholds задокументирован в module-level константах.
"""
factory, captured = _capturing_session_factory()
monkeypatch.setattr(nspd_geo_mod, "SessionLocal", factory)
cleanup_zombies()
_sql, params = captured[0]
assert params["running_threshold"] == _ZOMBIE_RUNNING_THRESHOLD
assert params["paused_threshold"] == _ZOMBIE_PAUSED_THRESHOLD
# Контракт: running threshold выше старых 2 мин (которые ре-enqueue'или живые WAF-jobs)
# и выше max WAF backoff (WAF_BACKOFF_BASE_S * 8 = 240s = 4 мин).
assert _ZOMBIE_RUNNING_THRESHOLD == "6 minutes"
# Контракт: paused threshold большой (WAF-пауза должна реально удержаться).
assert _ZOMBIE_PAUSED_THRESHOLD == "30 minutes"
def test_cleanup_zombies_splits_running_and_paused_in_where_clause(
monkeypatch: Any,
) -> None:
"""SQL разделяет 'running' и 'paused' с разными порогами — не общий IN-фильтр."""
factory, captured = _capturing_session_factory()
monkeypatch.setattr(nspd_geo_mod, "SessionLocal", factory)
cleanup_zombies()
sql, _params = captured[0]
executable = _strip_sql_comments(sql)
# Старая ветка с общим INTERVAL '2 minutes' для обоих статусов удалена
assert "INTERVAL '2 minutes'" not in executable
assert "status IN ('running', 'paused')" not in executable
# Новая ветка: status='running' и status='paused' в отдельных предикатах
assert "status = 'running'" in executable
assert "status = 'paused'" in executable
# Каждый из двух предикатов сравнивает heartbeat_at с NOW() - threshold
assert executable.count("heartbeat_at") >= 2
def test_cleanup_zombies_paused_threshold_strictly_greater_than_running(
monkeypatch: Any,
) -> None:
"""paused cooldown должен быть СТРОГО больше running, иначе пауза аннулируется минутным beat."""
factory, captured = _capturing_session_factory()
monkeypatch.setattr(nspd_geo_mod, "SessionLocal", factory)
cleanup_zombies()
# 6 vs 30 — числовое сравнение префиксов как guard от случайного "6 minutes" == "30 minutes"
running_min = int(_ZOMBIE_RUNNING_THRESHOLD.split()[0])
paused_min = int(_ZOMBIE_PAUSED_THRESHOLD.split()[0])
assert paused_min > running_min, (
"paused threshold должен быть строго больше running, "
"иначе WAF-пауза не удержится дольше running-cooldown"
)
# Sanity: SQL действительно отправлен с двумя разными порогами
_sql, params = captured[0]
assert params["running_threshold"] != params["paused_threshold"]