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
Находка 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>
112 lines
6.8 KiB
Python
112 lines
6.8 KiB
Python
"""Сторож на дома, уехавшие от собственных объявлений (#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 %)
|
||
1–5 км 64
|
||
5–25 км 40
|
||
25–100 км 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"
|