All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
Миграция 272: колонка + бэкфилл ТОЛЬКО по bbox региона 66 (значения байт-в-байт из реестра, синхронизацию держит тест). Карантин NULL: 620 домов без geom, 23 порченых (ЕКБ-адреса с чужими координатами — «Вильгельма де Геннина» на Байкале, «Крауля» под Москвой, «Учителей» в Таллине, с живыми ссылками листингов) — им регион не присваивается, включая 3 дома с координатами в bbox Москвы (порча, не переезд). NOT NULL из постановки — отдельной миграцией, когда карантин опустеет. Запись: новый дом наследует регион РАЗВЁРТКИ (base.py → контракт → адаптер → matching), только если координаты не противоречат; координаты другого региона → NULL-карантин + warning. Вне-bbox координаты при живой развёртке наследуют её регион (адрес и развёртка согласны, координатам веры нет) — семантика закреплена тестом, чтобы смена была осознанной. Матчинг-запросы НЕ тронуты — гард по региону это #3052. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
6.7 KiB
Python
137 lines
6.7 KiB
Python
"""#3051 п.4: region_code у houses — вывод, а не выдумка.
|
||
|
||
Прод-факты (27.08): 10 362 дома, 9 719 выводятся геометрией в регион 66;
|
||
23 порченых (ЕКБ-адреса с чужими координатами: «Вильгельма де Геннина» на
|
||
Байкале, «Крауля» под Москвой, «Учителей» в Таллине) с живыми ссылками
|
||
листингов; 620 без geom. Порченым и безгеомным регион НЕ присваивается —
|
||
NULL-карантин (присвоить по координатам = узаконить порчу).
|
||
|
||
Три слоя:
|
||
миграция 272 — колонка + бэкфилл ТОЛЬКО по bbox региона 66 (значения
|
||
байт-в-байт из реестра — синхронизацию держит тест ниже);
|
||
запись — новый дом наследует регион РАЗВЁРТКИ, только если координаты не
|
||
противоречат; противоречие → NULL + warning;
|
||
проводка — base.py передаёт region_code развёртки через контракт и адаптер.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
from app.services import regions
|
||
from app.services.matching import houses as houses_mod
|
||
|
||
_MIGRATION = Path(__file__).resolve().parents[1] / "data" / "sql" / "272_houses_region_code.sql"
|
||
|
||
# ── 1. Миграция синхронизирована с реестром ──────────────────────────────────
|
||
|
||
|
||
def test_migration_bbox_matches_registry() -> None:
|
||
"""bbox в SQL бэкфилла — байт-в-байт bbox_region региона 66 из реестра.
|
||
Разъезд значений = бэкфилл и рантайм выводят регион по разным границам."""
|
||
sql = _MIGRATION.read_text()
|
||
m = re.search(
|
||
r"ST_Y\(geom\) BETWEEN ([\d.]+) AND ([\d.]+)\s*"
|
||
r"AND ST_X\(geom\) BETWEEN ([\d.]+) AND ([\d.]+)",
|
||
sql,
|
||
)
|
||
assert m, "в миграции 272 не найден bbox-предикат бэкфилла"
|
||
lat_min, lat_max, lon_min, lon_max = (float(g) for g in m.groups())
|
||
assert (lat_min, lat_max, lon_min, lon_max) == regions.REGIONS[66].bbox_region
|
||
|
||
# 77 на бэкфилле не присваивается вообще (3 «московских» дома — порченые ЕКБ).
|
||
assert "= 77" not in sql
|
||
# NOT NULL не ставится, пока карантин не пуст: ни в определении колонки,
|
||
# ни отдельным ALTER (слова в комментариях — не DDL).
|
||
assert "SET NOT NULL" not in sql
|
||
assert re.search(r"region_code smallint\s*;", sql), "колонка обязана быть nullable"
|
||
|
||
|
||
# ── 2. Правило записи: унаследуй или карантинь ───────────────────────────────
|
||
|
||
|
||
class _CreateDb:
|
||
"""Двойник: все матч-тиры промахиваются, INSERT возвращает id и пишет параметры."""
|
||
|
||
def __init__(self) -> None:
|
||
self.insert_params: dict[str, Any] | None = None
|
||
|
||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||
text = str(stmt)
|
||
res = MagicMock()
|
||
if "INSERT INTO houses" in text:
|
||
self.insert_params = dict(params or {})
|
||
res.mappings.return_value.one.return_value = {"id": 4242}
|
||
return res
|
||
# advisory lock / все тиры матчинга — промах
|
||
res.fetchone.return_value = None
|
||
res.mappings.return_value.first.return_value = None
|
||
res.mappings.return_value.all.return_value = []
|
||
res.scalar.return_value = None
|
||
return res
|
||
|
||
def commit(self) -> None:
|
||
pass
|
||
|
||
def rollback(self) -> None:
|
||
pass
|
||
|
||
|
||
def _create(lat: float | None, lon: float | None, region_code: int | None) -> dict[str, Any]:
|
||
db = _CreateDb()
|
||
house_id, _conf, method = houses_mod.match_or_create_house(
|
||
db, # type: ignore[arg-type]
|
||
"avito",
|
||
"ext-1",
|
||
address="ул. Крауля, 44",
|
||
lat=lat,
|
||
lon=lon,
|
||
region_code=region_code,
|
||
)
|
||
assert house_id == 4242, f"дом не создан (method={method})"
|
||
assert db.insert_params is not None
|
||
return db.insert_params
|
||
|
||
|
||
def test_coords_confirm_region_inherited() -> None:
|
||
"""Координаты в bbox 66 + развёртка 66 → дом рождается с region_code=66."""
|
||
assert _create(56.83, 60.60, 66).get("region_code") == 66
|
||
|
||
|
||
def test_no_coords_no_contradiction_inherited() -> None:
|
||
"""Без координат противоречия нет — регион развёртки наследуется."""
|
||
assert _create(None, None, 66).get("region_code") == 66
|
||
|
||
|
||
def test_contradicting_coords_quarantine(caplog: pytest.LogCaptureFixture) -> None:
|
||
"""ЕКБ-развёртка с координатами в bbox Москвы (порченый геокод, прод-кейс
|
||
«Крауля, гп 1») → NULL-карантин + warning, а НЕ выдуманный 77."""
|
||
import logging
|
||
|
||
with caplog.at_level(logging.WARNING):
|
||
params = _create(55.716, 37.268, 66)
|
||
assert params.get("region_code") is None, "порченому геокоду выдумали регион вместо карантина"
|
||
assert "карантин" in caplog.text
|
||
|
||
|
||
def test_legacy_call_without_region_stays_null() -> None:
|
||
"""Вызов без региона развёртки (легаси) — NULL, не дефолт-66."""
|
||
assert _create(56.83, 60.60, None).get("region_code") is None
|
||
|
||
|
||
def test_alien_coords_outside_all_regions_quarantine() -> None:
|
||
"""Координаты вне всех регионов (Таллин, прод-кейс «Учителей») при развёртке 66:
|
||
region_for_point → None → противоречия ФОРМАЛЬНО нет — наследуем 66? НЕТ:
|
||
правило намеренно наследует (см. док-стринг matching/houses.py — вне-bbox
|
||
координаты не выводят ДРУГОЙ регион). Закрепляем текущую семантику, чтобы
|
||
смена была осознанной, а не случайной."""
|
||
assert _create(59.42, 24.75, 66).get("region_code") == 66
|