gendesign/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py
bot-backend fab0bc84c1
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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 4m49s
feat(tradein): ручка «дома, уехавшие от собственных объявлений» (#2996)
Находка 3 задачи («23 дома лежат вне области 66, гарда на приёме нет»)
подтвердилась, и к ней добавились масштаб и причина.

Масштаб: к этим домам привязано 591 объявление. Адреса екатеринбургские
(«Ул. 8 Марта», «Крауля», «Амундсена»), координаты — Варшава, Белград,
Таллин, Владивосток, Ижевск.

Причина: 22 из 23 несут в raw_payload след разового бэкфилла
`full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без
резолва города. Живой путь при этом ЧИСТ — 0 записей вне региона в
geocode_cache из 10 448 и 2 из 88 544 у listings; скрипта в репозитории
нет. Это исторический осадок, а не текущая утечка, и гард нужен не
столько живому пути, сколько следующему разовому скрипту.

17 из 22 помечены геокодером `precision: "exact"`. Точность отвечает на
«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть
не может — поэтому проверяется ПРИНАДЛЕЖНОСТЬ.

Инвариант нарочно не географический: «дом рядом со своими объявлениями»,
а не «дом внутри рамки области». Рамка сломалась бы при выходе в Москву —
ровно то, ради чего заведена #2996. Он же строго сильнее: ловит 25 домов
против 23 у рамки, и оба лишних проверены («Ул. Белинского/Фурманова» за
207 км, 34 объявления).

Порог 100 км не подобран на глаз. Замер по 9 052 домам: ближе 1 км —
8 914 (98.5 %), 25-100 км — 9 настоящих пригородов (Сарапулка, Кедровка,
Чусовское Озеро, Ревда, Первоуральск; самый дальний 46.7 км), дальше
100 км — 25 (ближайший 119.2, максимум 5 079). Между 46.7 и 119.2 км нет
НИ ОДНОГО дома: порог лежит в середине пустого промежутка.

Первая редакция клала счётчик полем в /scraper/data-quality. Замер это
остановил: запрос стоит ~445 мс на тёплом кэше, а обе существующие
выборки той ручки вместе — 27 мс, при опросе фронтом каждые 120 с. То
есть 17-кратное удорожание ради числа, которое меняется раз в месяцы.
Проверка вынесена в отдельную ручку по требованию, и на возврат в горячий
путь поставлен контроль-тест.

Ручка отдаёт не только счётчик, но и масштаб (список домов + сколько
объявлений привязано) — по одному числу «25» решение об очистке не
принять.

Двусторонне: против origin/main три теста красные, краснота везде по
значению — ни одного ImportError/AttributeError. Контроль
test_check_stays_out_of_the_polled_endpoint зелёный с обеих сторон.

pytest tradein-mvp/backend — 4642 passed, 23 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:28:50 +05:00

112 lines
6.8 KiB
Python
Raw Permalink 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.

"""Сторож на дома, уехавшие от собственных объявлений (#2996).
Прод 21.08.2026: 23 дома стоят вне Свердловской области, к ним привязано 591
объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»),
координаты — Варшава, Белград, Таллин, Владивосток, Ижевск.
Причина найдена и она НЕ в живом пути: 22 из 23 несут в `raw_payload` след
разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер
напрямую, без резолва города. Живой путь чист — 0 записей вне региона в
`geocode_cache` из 10 448 и 2 из 88 544 у `listings`.
**17 из 22 помечены геокодером `precision: "exact"`.** Точность отвечает на
«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не
может. Поэтому сторож проверяет ПРИНАДЛЕЖНОСТЬ, а не «точность».
Инвариант нарочно не географический — «дом рядом со своими объявлениями», а не
«дом внутри рамки области». Рамка сломалась бы при выходе в Москву; этот
инвариант переживёт расширение.
Порог 100 км не подобран на глаз. Замер по 9 052 домам с объявлениями:
ближе 1 км 8 914 (98.5 %)
15 км 64
525 км 40
25100 км 9 ← настоящие пригороды: Сарапулка, Кедровка,
Чусовское Озеро, Ревда, Первоуральск (макс. 46.7)
дальше 100 км 25 ← ближайший 119.2, максимум 5 079
Между 46.7 и 119.2 км нет НИ ОДНОГО дома. 100 лежит в середине пустого
промежутка, и ловит на два дома больше, чем рамка по области (оба проверены —
настоящие, «Ул. Белинского/Фурманова» за 207 км с 34 объявлениями).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import inspect
import re
def test_threshold_sits_in_the_measured_gap() -> None:
"""Головной: порог задан и лежит между пригородами и патологией.
46.7 км — самый дальний настоящий пригород, 119.2 — ближайший дефект.
Порог вне этого промежутка либо ловит пригороды, либо пропускает дефекты.
"""
from app.api.v1 import admin
порог = getattr(admin, "_MISLOCATED_KM", None)
assert порог is not None, (
"порога нет вовсе — сторож не задан, дома в Варшаве и Таллине никем " "не считаются"
)
assert 46.7 < порог < 119.2, (
f"порог {порог} вне измеренного промежутка (46.7, 119.2): ниже — ловит "
"настоящие пригороды, выше — пропускает дефекты"
)
def test_endpoint_exists_and_reports_scale() -> None:
"""Ручка обязана отдавать не только счётчик, но и МАСШТАБ.
Одно число «25» не даёт принять решение об очистке: нужно, сколько
объявлений привязано к этим домам (на проде 591) и какие это дома.
"""
from app.api.v1 import admin
ручка = getattr(admin, "get_mislocated_houses", None)
модель = getattr(admin, "MislocatedHousesResponse", None)
assert ручка is not None and модель is not None, (
"ручки нет — дома в Варшаве и Таллине не перечисляет никто; "
f"есть ли обработчик: {ручка is not None}, есть ли модель: {модель is not None}"
)
поля = модель.model_fields
for нужное in ("threshold_km", "houses_count", "listings_count", "houses"):
assert нужное in поля, f"нет поля {нужное}; есть: {sorted(поля)}"
def test_query_compares_house_to_its_own_listings() -> None:
"""Контроль смысла: инвариант «рядом со своими», а не «внутри рамки».
Рамка по области сломалась бы при выходе в Москву — ровно то, ради чего
заведена #2996. Ловит «починку» через координатный BETWEEN.
"""
from app.api.v1 import admin
запрос = getattr(admin, "_MISLOCATED_SQL", None)
assert запрос is not None, "запроса нет — проверять принадлежность нечем"
src = str(запрос)
assert "l.house_id_fk = h.id" in src, "дом не сравнивается со СВОИМИ объявлениями"
assert "percentile_disc(0.5)" in src, "берётся не медиана координат объявлений"
assert not re.search(
r"ST_Y\(.*BETWEEN", src
), "появилась географическая рамка — она не переживёт расширение региона"
def test_check_stays_out_of_the_polled_endpoint() -> None:
"""Контроль цены: тяжёлая проверка не должна попасть в опрашиваемую ручку.
Замер прода: этот запрос ~445 мс, обе выборки /scraper/data-quality вместе —
27 мс, а фронт опрашивает её каждые 120 с. Первая редакция этой правки клала
проверку туда и давала 17-кратное удорожание; замер это и остановил.
"""
from app.api.v1 import admin
src = inspect.getsource(admin.get_data_quality)
assert (
"percentile_disc" not in src
), "тяжёлый агрегат вернулся в опрашиваемую каждые 2 минуты ручку"
assert "mislocated" not in src.lower(), "проверка снова подмешана в data-quality"