/.
+# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
#
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
@@ -72,6 +79,39 @@ __all__ = [
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+"
+# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
+# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
+# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
+#
+# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
+# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
+# вставленных после самой свежей его строки, id 2583989), и выйти из него
+# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
+# множество и обязано быть константой — вопрос был не «почему не растёт», а
+# «правда ли они непригодны».
+#
+# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
+# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
+# него собирается — это инвариант #2235 (`_canonical_source_url` в
+# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
+# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
+# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
+# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
+#
+# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
+# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
+# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
+# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
+# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
+# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
+#
+# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
+# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
+# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
+# на сайты застройщиков вместо Яндекса.
+OFFER_ID_PATTERN = "^[0-9]+$"
+CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
+
@dataclass
class YandexDetailBackfillResult:
@@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
failed: int = 0
+ # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
+ # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
+ # прогону; замер на месте = очередь снова читается не тем признаком.
+ url_from_offer_id: int = 0
+ # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0
duration_sec: float = field(default=0.0)
@@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"failed": self.failed,
+ "url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec),
}
@@ -132,51 +178,86 @@ async def run_yandex_detail_backfill(
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
- # Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
- # (см. комментарий у константы): в очередь не берём то, что заведомо
- # непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
+ # В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
+ # сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
+ # собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
+ # этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
snapshot = (
db.execute(
text(
- """
- SELECT id, source_url
+ f"""
+ SELECT id,
+ CASE
+ WHEN source_url ~ CAST(:offer_url_pattern AS text)
+ THEN source_url
+ ELSE {CANONICAL_URL_SQL}
+ END AS source_url
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
- AND source_url IS NOT NULL
- AND source_url ~ CAST(:offer_url_pattern AS text)
+ AND (
+ (
+ source_url IS NOT NULL
+ AND source_url ~ CAST(:offer_url_pattern AS text)
+ )
+ OR source_id ~ CAST(:offer_id_pattern AS text)
+ )
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
+ # f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
+ # не пользовательский ввод. Всё изменяемое — bind-параметры.
),
- {"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
+ {
+ "batch_size": batch_size,
+ "offer_url_pattern": OFFER_URL_PATTERN,
+ "offer_id_pattern": OFFER_ID_PATTERN,
+ },
)
.mappings()
.all()
)
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
- # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
- counters.unenrichable_pending = int(
- db.execute(
- text(
- """
- SELECT count(*)
- FROM listings
- WHERE source = 'yandex'
- AND detail_enriched_at IS NULL
- AND source_url IS NOT NULL
- AND source_url !~ CAST(:offer_url_pattern AS text)
- """
- ),
- {"offer_url_pattern": OFFER_URL_PATTERN},
- ).scalar_one()
- )
- if counters.unenrichable_pending:
+ # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
+ # разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
+ # «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
+ pending = db.execute(
+ text(
+ """
+ SELECT
+ count(*) FILTER (
+ WHERE source_id ~ CAST(:offer_id_pattern AS text)
+ ) AS url_from_offer_id,
+ count(*) FILTER (
+ WHERE source_id IS NULL
+ OR source_id !~ CAST(:offer_id_pattern AS text)
+ ) AS unenrichable_pending
+ FROM listings
+ WHERE source = 'yandex'
+ AND detail_enriched_at IS NULL
+ AND (
+ source_url IS NULL
+ OR source_url !~ CAST(:offer_url_pattern AS text)
+ )
+ """
+ ),
+ {"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
+ ).one()
+ counters.url_from_offer_id = int(pending.url_from_offer_id)
+ counters.unenrichable_pending = int(pending.unenrichable_pending)
+ if counters.url_from_offer_id:
logger.info(
- "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: "
- "source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
- "до сети",
+ "yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
+ "source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
+ "source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
+ run_id,
+ counters.url_from_offer_id,
+ )
+ if counters.unenrichable_pending:
+ logger.warning(
+ "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни "
+ "offer-URL (%s), ни числового source_id — адресовать их нечем",
run_id,
counters.unenrichable_pending,
OFFER_URL_PATTERN,
@@ -203,8 +284,15 @@ async def run_yandex_detail_backfill(
max_consecutive_blocks,
)
- # Build proxies dict once — mirrors yandex_address_backfill.py
- _proxy = settings.scraper_proxy_url
+ # Build proxies dict once — mirrors yandex_address_backfill.py.
+ # Резолвер по источнику (#2825): пул scrape_proxies с учётом
+ # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если
+ # пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
+ # нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError
+ # (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже
+ # (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с
+ # понятным логом, отдельный catch не нужен.
+ _proxy = resolve_proxy_url(db, "yandex")
_proxies = {"http": _proxy, "https": _proxy} if _proxy else None
consecutive_none = 0
diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql
new file mode 100644
index 00000000..61120223
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql
@@ -0,0 +1,155 @@
+-- 254_listings_backfill_avito_rating_glued_address.sql
+-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814).
+--
+-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же
+-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
+-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но
+-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет
+-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при
+-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от
+-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный
+-- путь, которым старые строки могут стать чистыми.
+--
+-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным):
+--
+-- класс адреса (source='avito', is_active) строк с координатами
+-- ------------------------------------------ ------ --------------
+-- чистый 7892 6111 (77.4%)
+-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%)
+-- NULL 360 0 (0.0%)
+--
+-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался
+-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE.
+-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет.
+-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов
+-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается).
+--
+-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь.
+-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile(
+-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I)
+-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t")
+-- → `return cleaned or None`.
+-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace =
+-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов
+-- в trim, NULLIF(...,'') = `or None`.
+-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА.
+-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост
+-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми
+-- (проверено в dry-run: 296 до = 296 после).
+-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после
+-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений
+-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем.
+--
+-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с
+-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере:
+-- docker exec tradein-scraper python /tmp/m2814-parity.py
+-- → rows=1123 mismatches=0
+-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде.
+--
+-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10):
+-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296
+-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17
+-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б
+-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103
+-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5
+-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60
+-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2
+-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2
+-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов
+-- → жилой район Сортировочный, мкр-н Старая
+-- Сортировка, Кунарская ул.,14к2
+-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов
+-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18
+-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес
+-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то
+-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты:
+-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.)
+--
+-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится.
+-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в
+-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) —
+-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк
+-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного.
+-- Откат (idempotent, безопасен к повторному запуску):
+--
+-- UPDATE listings
+-- SET address = raw_payload->>'address'
+-- WHERE source = 'avito'
+-- AND raw_payload->>'address' ~ '·\s*\d'
+-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM
+-- regexp_replace(raw_payload->>'address',
+-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), '');
+--
+-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не
+-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно
+-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123.
+-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный,
+-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская
+-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО:
+-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам
+-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует
+-- в порчу.
+-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные.
+--
+-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по
+-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ
+-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит
+-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не
+-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не
+-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен:
+-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до
+-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк.
+--
+-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции
+-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в
+-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL
+-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь
+-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже
+-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход
+-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200,
+-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC.
+-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит
+-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245
+-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт
+-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53%
+-- успеха на адрес, гадать точнее не буду.
+--
+-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
+-- * не трогает COALESCE в апсерте — поведение осознанное (#2777);
+-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем
+-- свипе (замерено: пустых строк '' среди них 0, все именно NULL);
+-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть
+-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city),
+-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у
+-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это
+-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь.
+--
+-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql
+-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE
+-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет.
+-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает
+-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815.
+-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт
+-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та
+-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт
+-- громко (ON_ERROR_STOP=on), чем встанет тихо.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+UPDATE listings
+SET address = NULLIF(
+ trim(both E' ,.\n\t' FROM
+ regexp_replace(
+ address,
+ '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$',
+ '',
+ 'i'
+ )),
+ ''),
+ geocode_tried_at = NULL
+WHERE source = 'avito'
+ AND address ~ '·\s*\d';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql
new file mode 100644
index 00000000..de19a1f4
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql
@@ -0,0 +1,77 @@
+-- 255_trade_in_estimates_revival_relaxations.sql
+-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно
+-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
+-- (108_*/084_* уже дублировались в прошлом).
+--
+-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ───────────────────────
+-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО
+-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR
+-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и
+-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071
+-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30
+-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py::
+-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и
+-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две
+-- новые колонки:
+--
+-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N
+-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну
+-- строку. Заявка на пересчёт — атомарный conditional
+-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min
+-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) —
+-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки
+-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что
+-- есть, без 500).
+--
+-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy
+-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ──
+-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST-
+-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ
+-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный
+-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на
+-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной
+-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET.
+--
+-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
+-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие
+-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_
+-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений
+-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался.
+--
+-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+ALTER TABLE trade_in_estimates
+ ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb;
+
+ALTER TABLE trade_in_estimates
+ ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok'
+ CHECK (reliability IN ('ok', 'low', 'very_low'));
+
+ALTER TABLE trade_in_estimates
+ ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz;
+
+COMMENT ON COLUMN trade_in_estimates.relaxations IS
+ 'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) '
+ '— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер '
+ '«точность снижена». [] = базовой выборки хватило / запись создана до '
+ '#oblast-F (без бэкфилла).';
+
+COMMENT ON COLUMN trade_in_estimates.reliability IS
+ 'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + '
+ 'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate '
+ 'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).';
+
+COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS
+ 'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) '
+ 'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::'
+ '_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в '
+ 'settings.trade_in_revival_throttle_minutes на одну строку — атомарный '
+ 'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо '
+ 'строка живая (median_price>0) и revival никогда не запускался, либо '
+ 'запись создана до этой фичи.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql
new file mode 100644
index 00000000..499f2f79
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql
@@ -0,0 +1,48 @@
+-- 256_trade_in_estimates_revival_completed_at.sql
+-- Номер сверен по `ls data/sql | sort` (max applied = 255) непосредственно
+-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
+-- (108_*/084_* уже дублировались в прошлом).
+--
+-- ── fix/tradein-created (2026-08-11): created_at перезаписывался revival'ом ──
+-- `_try_revive_dead_estimate` (app/api/v1/trade_in.py, migration 255) писал
+-- пересчитанные поля в ИСХОДНУЮ строку trade_in_estimates, и вместе с ними —
+-- `created_at`, скопированный из временной строки (estimate_quality() ставит
+-- туда NOW() на момент пересчёта). Факт с прода: оценка
+-- ff421062-cc38-4c4c-ad2e-0cfac52d14ff создана 2026-08-10 12:54:47, после
+-- revival'а на GET её created_at стал 2026-08-11 04:30:03 — «дата обращения»
+-- клиента (печатается в /history и в схеме AggregatedEstimate.created_at,
+-- см. app/schemas/trade_in.py:317-318 «для метки «отчёт от DD.MM» в UI»)
+-- подменилась датой служебного пересчёта. Заодно ломался ORDER BY created_at
+-- DESC в GET /history — оживлённая старая заявка выпрыгивала в начало списка.
+--
+-- Фикс (app/api/v1/trade_in.py): created_at исключён из UPDATE SET revival'а,
+-- исходная дата больше не трогается. Момент, когда revival РЕАЛЬНО пересчитал
+-- строку (не просто "попытался" — revival_attempted_at из 255 ставится на
+-- claim'е ДО вызова estimate_quality(), в том числе при throttle-проигрыше и
+-- при неудачном пересчёте), нужен для аудита отдельно — новая колонка.
+--
+-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
+-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: NULL = либо
+-- строка живая и revival никогда успешно не пересчитывал, либо запись
+-- создана до этой колонки.
+--
+-- Dependencies: 255_trade_in_estimates_revival_relaxations.sql. Apply after: 255_*.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+ALTER TABLE trade_in_estimates
+ ADD COLUMN IF NOT EXISTS revival_completed_at timestamptz;
+
+COMMENT ON COLUMN trade_in_estimates.revival_completed_at IS
+ 'Момент УСПЕШНОГО пересчёта «мёртвой» (median_price<=0/NULL) строки '
+ 'revival''ом (app/api/v1/trade_in.py::_try_revive_dead_estimate) — '
+ 'выставляется, когда пересчёт реально записал новые значения в строку. '
+ 'Отличается от revival_attempted_at (255): тот ставится на claim''е ДО '
+ 'вызова estimate_quality() и фиксирует ЛЮБУЮ попытку (включая throttled-'
+ 'проигрыш гонки и неудачный пересчёт), этот — только успех. created_at '
+ 'строки при этом НЕ меняется (исходная дата обращения клиента, печатается '
+ 'в /history) — см. fix/tradein-created 2026-08-11.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql
new file mode 100644
index 00000000..05f29a7c
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql
@@ -0,0 +1,150 @@
+-- 257_listings_backfill_yandex_source_url.sql
+-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838).
+--
+-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`,
+-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235,
+-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки,
+-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared`
+-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку
+-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки.
+-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/`
+-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются
+-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты.
+-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция.
+--
+-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»):
+--
+-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк
+-- ------------------------------------------------------- -----
+-- всего 3535
+-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535
+-- из них source_id NULL/нечисловой (нечем адресовать) 0
+-- из них source_url IS NULL 0
+-- из них is_active 3522
+--
+-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356,
+-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167,
+-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114,
+-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост.
+--
+-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем):
+-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк,
+-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет.
+-- Множество может только уменьшаться (удаление строк), не расти.
+--
+-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь.
+-- scraper_kit/providers/yandex/serp.py::_canonical_source_url:
+-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex»
+-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL
+-- "'https://realty.yandex.ru/offer/' || source_id || '/'"
+-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно;
+-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление
+-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою
+-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py).
+-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+')
+-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки.
+--
+-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО:
+-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0;
+-- * два кандидата с одинаковым новым URL внутри самого множества: 0
+-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql);
+-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0.
+--
+-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже):
+-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0
+-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0
+-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0
+--
+-- было → стало (10 строк, взяты по id DESC):
+-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/
+-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/
+-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/
+-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/
+-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/
+-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/
+-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/
+-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/
+-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/
+-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/
+-- (префикс «стало» везде один: https://realty.yandex.ru/offer//)
+-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же
+-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK.
+--
+-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено).
+-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был
+-- raw_payload->>'address') здесь НЕ работает:
+-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там
+-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type,
+-- site_name — адреса нет ни под одним именем;
+-- * listings.house_url / newbuilding_url у всех 3535 = NULL;
+-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE
+-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить
+-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не
+-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его
+-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки,
+-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика
+-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе
+-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес).
+-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат:
+--
+-- UPDATE listings l
+-- SET source_url = b.old_source_url
+-- FROM yandex_source_url_backfill_257 b
+-- WHERE l.id = b.listing_id
+-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/';
+--
+-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал
+-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая
+-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто
+-- удалить — на приложение она не влияет, читателей у неё нет.
+--
+-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
+-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py —
+-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там
+-- COALESCE в апсерте так же намеренно не трогали);
+-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по
+-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая
+-- история того, что отдал gate-API;
+-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай»
+-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт);
+-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка.
+--
+-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql
+-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула).
+-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL →
+-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT
+-- DO NOTHING делают повтор безопасным и при частичном откате.
+-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py
+-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и
+-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед
+-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 (
+ listing_id bigint PRIMARY KEY,
+ old_source_url text NOT NULL,
+ changed_at timestamptz NOT NULL DEFAULT now()
+);
+
+COMMENT ON TABLE yandex_source_url_backfill_257 IS
+ 'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). '
+ 'Только для отката; читателей в приложении нет, удаляется без последствий.';
+
+INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url)
+SELECT id, source_url
+ FROM listings
+ WHERE source = 'yandex'
+ AND source_url !~ '/offer/[0-9]+'
+ AND source_id ~ '^[0-9]+$'
+ON CONFLICT (listing_id) DO NOTHING;
+
+UPDATE listings
+ SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/'
+ WHERE source = 'yandex'
+ AND source_url !~ '/offer/[0-9]+'
+ AND source_id ~ '^[0-9]+$';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql
new file mode 100644
index 00000000..12f72b4b
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql
@@ -0,0 +1,41 @@
+-- 258_houses_imv_transient_attempts.sql
+-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
+--
+-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
+-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
+-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен
+-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из
+-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All
+-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой
+-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе
+-- 108 из 108 POST /fetch-json = 200).
+--
+-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически
+-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из
+-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт
+-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд.
+--
+-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error',
+-- прежние разрезы по imv_status/imv_error_reason работают как работали, а
+-- «застряли окончательно» — это
+-- SELECT count(*) FROM houses
+-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3;
+--
+-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по
+-- imv_status IN ('pending','transient_error') с сортировкой по
+-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на
+-- выборке в тысячи строк.
+
+BEGIN;
+
+SET LOCAL lock_timeout = '5s';
+
+ALTER TABLE houses
+ ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0;
+
+COMMENT ON COLUMN houses.imv_transient_attempts IS
+ 'Сколько раз подряд домовая IMV-оценка падала в transient_error. '
+ 'Растёт только на transient_error, обнуляется успехом. '
+ '>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).';
+
+COMMIT;
diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
index a040e084..b11e827e 100644
--- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
+++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
@@ -8,7 +8,7 @@
},
"low": {
"coverage_pct": 82.09,
- "mape_pct": 13.2,
+ "mape_pct": 12.67,
"n": 276,
"n_covered": 220
},
@@ -26,22 +26,22 @@
],
"expected_sold": {
"overall": {
- "mape_pct": 13.18,
- "median_bias_pct": -3.71,
+ "mape_pct": 12.63,
+ "median_bias_pct": -3.74,
"n": 269,
"n_no_analogs": 0,
- "p25_pct": -16.11,
- "p75_pct": 8.92
+ "p25_pct": -15.17,
+ "p75_pct": 8.67
},
"per_rooms": {
"0": {
"label": "студия",
- "mape_pct": 19.38,
- "median_bias_pct": 18.1,
+ "mape_pct": 16.96,
+ "median_bias_pct": 16.96,
"n": 35,
"n_no_analogs": 0,
"p25_pct": 1.5,
- "p75_pct": 34.32
+ "p75_pct": 38.82
},
"1": {
"label": "1к",
@@ -50,50 +50,50 @@
"n": 93,
"n_no_analogs": 0,
"p25_pct": -14.43,
- "p75_pct": 6.98
+ "p75_pct": 8.15
},
"2": {
"label": "2к",
- "mape_pct": 18.26,
- "median_bias_pct": -12.2,
+ "mape_pct": 17.39,
+ "median_bias_pct": -11.71,
"n": 74,
"n_no_analogs": 0,
- "p25_pct": -24.35,
+ "p25_pct": -23.07,
"p75_pct": -0.36
},
"3": {
"label": "3к",
- "mape_pct": 9.34,
- "median_bias_pct": -3.08,
+ "mape_pct": 7.79,
+ "median_bias_pct": -4.05,
"n": 43,
"n_no_analogs": 0,
"p25_pct": -10.26,
- "p75_pct": 4.68
+ "p75_pct": 3.82
},
"4": {
"label": "4+",
- "mape_pct": 16.3,
- "median_bias_pct": 3.34,
+ "mape_pct": 16.38,
+ "median_bias_pct": -1.32,
"n": 24,
"n_no_analogs": 0,
"p25_pct": -14.91,
- "p75_pct": 15.11
+ "p75_pct": 15.41
}
},
"per_segment": {
"бизнес": {
- "mape_pct": 14.65,
+ "mape_pct": 13.65,
"median_bias_pct": -10.54,
"n": 46,
- "p25_pct": -22.93,
+ "p25_pct": -27.15,
"p75_pct": -1.31
},
"комфорт": {
- "mape_pct": 11.8,
- "median_bias_pct": -4.61,
+ "mape_pct": 10.14,
+ "median_bias_pct": -5.01,
"n": 101,
- "p25_pct": -17.07,
- "p75_pct": 6.04
+ "p25_pct": -15.49,
+ "p75_pct": 4.37
},
"премиум": {
"mape_pct": 68.92,
@@ -103,11 +103,11 @@
"p75_pct": -68.92
},
"эконом": {
- "mape_pct": 13.71,
- "median_bias_pct": 2.54,
+ "mape_pct": 14.2,
+ "median_bias_pct": 3.33,
"n": 115,
- "p25_pct": -9.8,
- "p75_pct": 25.5
+ "p25_pct": -8.69,
+ "p75_pct": 27.3
},
"элит": {
"mape_pct": 33.2,
diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
new file mode 100644
index 00000000..2e9da4bf
--- /dev/null
+++ b/tradein-mvp/backend/tests/scrapers/test_avito_clean_address_rating_tail.py
@@ -0,0 +1,59 @@
+"""Адрес Авито не должен утаскивать за собой рейтинг дома и число отзывов.
+
+Прод 2026-08-10: 1 123 активных объявления Авито с адресом вида
+«ул. Ткачей,17·5,0 · 4 отзыва», и у 1 123 из 1 123 нет координат — доля 100%.
+У объявлений с чистым адресом координаты есть у 4 766. Хвост появился 27.07.2026,
+когда Авито начал рендерить рейтинг внутри того же в item-location.
+"""
+
+from __future__ import annotations
+
+import pytest
+from scraper_kit.providers.avito.serp import _clean_address
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ # Живые строки с прода — рейтинг + счётчик отзывов.
+ ("Авиационная ул.,10·4,9 · 11 отзывов", "Авиационная ул.,10"),
+ ("ул. 8 Марта,204Г/2·4,3 · 3 отзыва", "ул. 8 Марта,204Г/2"),
+ ("ул. Академика Шварца,18к2·1 отзыв", "ул. Академика Шварца,18к2"),
+ # Только счётчик, без рейтинга.
+ ("ул. Фурманова,59·2 отзыва", "ул. Фурманова,59"),
+ # Пробелы вокруг разделителя.
+ ("ул. Ткачей, 17 · 5,0 · 4 отзыва", "ул. Ткачей, 17"),
+ ],
+)
+def test_rating_tail_stripped(raw: str, expected: str) -> None:
+ assert _clean_address(raw) == expected
+
+
+def test_district_marker_after_dot_survives() -> None:
+ """Режем «·» только перед цифрой — район за точкой остаётся на месте.
+
+ Иначе фикс рейтинга съел бы разделитель района, который _deglue_house_marker
+ намеренно восстанавливает (#1773).
+ """
+ assert (
+ _clean_address("улица Вильгельма де Геннина, 59 · р-н Академический")
+ == "улица Вильгельма де Геннина, 59 · р-н Академический"
+ )
+
+
+def test_clean_address_unchanged() -> None:
+ assert _clean_address("с. Новоалексеевское, ул. 8 Марта,35") == (
+ "с. Новоалексеевское, ул. 8 Марта,35"
+ )
+
+
+def test_existing_noise_still_stripped() -> None:
+ """Регрессия: старые хвосты (CSS/метро/«от N мин.») режутся как раньше."""
+ assert _clean_address("ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:red}") == (
+ "ул. Токарей, 56к1"
+ )
+ assert _clean_address("ул. Малышева, 1.css-xxx{color:blue}") == "ул. Малышева, 1"
+ # #1773: слипшийся маркер района по-прежнему расклеивается.
+ assert _clean_address("ул. Евгения Савкова, 29р-н Академический") == (
+ "ул. Евгения Савкова, 29, р-н Академический"
+ )
diff --git a/tradein-mvp/backend/tests/services/test_proxy_egress.py b/tradein-mvp/backend/tests/services/test_proxy_egress.py
new file mode 100644
index 00000000..cf303b2b
--- /dev/null
+++ b/tradein-mvp/backend/tests/services/test_proxy_egress.py
@@ -0,0 +1,332 @@
+"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616).
+
+Покрытие БЕЗ live-сети/БД: FakeSession эмулирует ДВА запроса над scrape_proxies +
+scrape_proxy_source_bans — основной SELECT кандидата (`_pick_candidate`) и, только
+когда он вернул пусто, diagnostic-агрегат (`_diagnose_no_candidate`) для различения
+"пул пуст" от "пул не пуст, все отсеяны".
+
+ - выбирается небанненный прокси;
+ - забаненный ДЛЯ ИСТОЧНИКА не выбирается;
+ - забаненный для ДРУГОГО источника — выбирается (суть #2600 п.2: Авито банит IP,
+ Яндекс через тот же IP ходит чисто);
+ - при нескольких кандидатах — меньший consecutive_fails выигрывает;
+ - при равном consecutive_fails — более свежий last_ok_at выигрывает;
+ - пул ПУСТ (0 строк вообще) → легитимный fallback на settings.scraper_proxy_url,
+ logger.WARNING с текстом «пуст»;
+ - пул пуст И SCRAPER_PROXY_URL не задан → None (прямое подключение), WARNING;
+ - пул НЕ пуст, но все кандидаты забанены/нездоровы/выключены → ProxyPoolExhaustedError
+ (fail-closed, #2616), logger.ERROR с разбивкой — env НЕ используется, даже если
+ задан;
+ - тексты "пуст" и "все отсеяны" в логах РАЗНЫЕ (не перепутать при чтении логов/алертов).
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import pytest
+
+from app.core.config import settings
+from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
+
+# ── stateful fake session (эмулирует ОБА read-only запроса proxy_egress) ──────────
+
+
+class _FakeResult:
+ def __init__(self, rows: list[dict[str, Any]]):
+ self._rows = rows
+
+ def mappings(self) -> _FakeResult:
+ return self
+
+ def fetchone(self) -> dict[str, Any] | None:
+ return self._rows[0] if self._rows else None
+
+
+class FakeSession:
+ def __init__(self, rows: list[dict[str, Any]], bans: list[dict[str, Any]] | None = None):
+ self.rows = rows
+ self.bans = bans or []
+
+ def _has_active_ban(self, pid: int, source: str) -> bool:
+ return any(
+ b["proxy_id"] == pid and b["source"] == source and b["banned_until"] > datetime.now(UTC)
+ for b in self.bans
+ )
+
+ def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
+ sql = str(stmt)
+ p = params or {}
+ assert "FROM scrape_proxies" in sql
+ assert "scrape_proxy_source_bans" in sql
+ max_fails = p["max_fails"]
+ source = p["source"]
+
+ if "pool_total" in sql: # _diagnose_no_candidate aggregate
+ unhealthy = sum(
+ 1 for r in self.rows if not r["enabled"] or r["consecutive_fails"] >= max_fails
+ )
+ banned = sum(
+ 1
+ for r in self.rows
+ if r["enabled"]
+ and r["consecutive_fails"] < max_fails
+ and self._has_active_ban(r["id"], source)
+ )
+ return _FakeResult(
+ [
+ {
+ "pool_total": len(self.rows),
+ "unhealthy_or_disabled": unhealthy,
+ "banned_for_source": banned,
+ }
+ ]
+ )
+
+ # _pick_candidate primary SELECT
+ cands = [
+ r
+ for r in self.rows
+ if r["enabled"]
+ and r["consecutive_fails"] < max_fails
+ and not self._has_active_ban(r["id"], source)
+ ]
+ cands.sort(
+ key=lambda r: (
+ r["consecutive_fails"],
+ -(r["last_ok_at"] or datetime.min.replace(tzinfo=UTC)).timestamp(),
+ r["id"],
+ )
+ )
+ return _FakeResult([dict(r) for r in cands[:1]])
+
+
+def _proxy(
+ id_: int,
+ *,
+ enabled: bool = True,
+ consecutive_fails: int = 0,
+ last_ok_at: datetime | None = None,
+ label: str | None = None,
+ url: str = "",
+) -> dict[str, Any]:
+ return {
+ "id": id_,
+ "url": url or f"http://user:pass@proxy{id_}.local:8080",
+ "label": label,
+ "enabled": enabled,
+ "consecutive_fails": consecutive_fails,
+ "last_ok_at": last_ok_at,
+ }
+
+
+@pytest.fixture(autouse=True)
+def _clear_fallback_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Изолируем тесты от реального прод-значения ENV (если случайно унаследовано).
+ monkeypatch.setattr(settings, "scraper_proxy_url_env", None)
+
+
+def test_picks_healthy_unbanned_proxy() -> None:
+ db = FakeSession([_proxy(1, url="http://u:p@good.local:8080")])
+ result = resolve_proxy_url(db, "avito")
+ assert result == "http://u:p@good.local:8080"
+
+
+def test_banned_for_source_raises_pool_exhausted() -> None:
+ """Пул НЕ пуст (1 узел), но он забанен для ИМЕННО этого источника — fail-closed,
+ НЕ fallback на env (#2616)."""
+ now = datetime.now(UTC)
+ db = FakeSession(
+ [_proxy(1, url="http://u:p@banned.local:8080")],
+ bans=[{"proxy_id": 1, "source": "avito", "banned_until": now + timedelta(hours=6)}],
+ )
+ with pytest.raises(ProxyPoolExhaustedError) as exc_info:
+ resolve_proxy_url(db, "avito")
+ assert exc_info.value.source == "avito"
+ assert exc_info.value.pool_total == 1
+ assert exc_info.value.banned_for_source == 1
+ assert exc_info.value.unhealthy_or_disabled == 0
+
+
+def test_banned_for_other_source_still_picked() -> None:
+ now = datetime.now(UTC)
+ db = FakeSession(
+ [_proxy(1, url="http://u:p@shared.local:8080")],
+ bans=[{"proxy_id": 1, "source": "cian", "banned_until": now + timedelta(hours=6)}],
+ )
+ # Забанен только для cian — для avito остаётся первосортным кандидатом.
+ result = resolve_proxy_url(db, "avito")
+ assert result == "http://u:p@shared.local:8080"
+
+
+def test_expired_ban_does_not_block() -> None:
+ now = datetime.now(UTC)
+ db = FakeSession(
+ [_proxy(1, url="http://u:p@revived.local:8080")],
+ bans=[{"proxy_id": 1, "source": "avito", "banned_until": now - timedelta(hours=1)}],
+ )
+ result = resolve_proxy_url(db, "avito")
+ assert result == "http://u:p@revived.local:8080"
+
+
+def test_tiebreak_lower_consecutive_fails_wins() -> None:
+ db = FakeSession(
+ [
+ _proxy(1, consecutive_fails=2, url="http://u:p@flaky.local:8080"),
+ _proxy(2, consecutive_fails=0, url="http://u:p@solid.local:8080"),
+ ]
+ )
+ result = resolve_proxy_url(db, "yandex")
+ assert result == "http://u:p@solid.local:8080"
+
+
+def test_tiebreak_fresher_last_ok_at_wins_on_equal_fails() -> None:
+ now = datetime.now(UTC)
+ db = FakeSession(
+ [
+ _proxy(
+ 1,
+ consecutive_fails=0,
+ last_ok_at=now - timedelta(hours=2),
+ url="http://u:p@stale.local:8080",
+ ),
+ _proxy(
+ 2,
+ consecutive_fails=0,
+ last_ok_at=now - timedelta(minutes=5),
+ url="http://u:p@fresh.local:8080",
+ ),
+ ]
+ )
+ result = resolve_proxy_url(db, "yandex")
+ assert result == "http://u:p@fresh.local:8080"
+
+
+def test_empty_pool_falls_back_to_env_with_warning(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Сценарий 1 (легитимный): 0 строк в scrape_proxies вообще — dev/staging без
+ БД-пула. fallback на env разрешён."""
+ monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://static-fallback.local:9999")
+ db = FakeSession([])
+ with caplog.at_level("WARNING"):
+ result = resolve_proxy_url(db, "cian")
+ assert result == "http://static-fallback.local:9999"
+ warnings = [rec for rec in caplog.records if rec.levelname == "WARNING"]
+ assert any("пуст" in rec.message.lower() for rec in warnings)
+ assert not any(rec.levelname == "ERROR" for rec in caplog.records)
+
+
+def test_empty_pool_and_no_env_returns_none_with_warning(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ db = FakeSession([])
+ with caplog.at_level("WARNING"):
+ result = resolve_proxy_url(db, "domclick")
+ assert result is None
+ assert any("прямым подключением" in rec.message for rec in caplog.records)
+
+
+def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Сценарий 2 (инцидент 2026-08-10): пул НЕ пуст (2 узла), оба забанены для
+ source — fail-closed. env ЗАДАН, но НЕ используется — это и есть сама суть фикса."""
+ monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://static-fallback.local:9999")
+ now = datetime.now(UTC)
+ db = FakeSession(
+ [_proxy(1), _proxy(2)],
+ bans=[
+ {"proxy_id": 1, "source": "cian", "banned_until": now + timedelta(hours=6)},
+ {"proxy_id": 2, "source": "cian", "banned_until": now + timedelta(hours=6)},
+ ],
+ )
+ with caplog.at_level("WARNING"):
+ with pytest.raises(ProxyPoolExhaustedError) as exc_info:
+ resolve_proxy_url(db, "cian")
+ assert exc_info.value.pool_total == 2
+ assert exc_info.value.banned_for_source == 2
+ assert exc_info.value.unhealthy_or_disabled == 0
+ errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
+ assert any("fail-closed" in rec.message.lower() for rec in errors)
+ # НЕ должно быть "обход пула" / "static-fallback" в логах — env не тронут.
+ assert not any("static-fallback" in rec.message for rec in caplog.records)
+
+
+def test_exhausted_and_empty_pool_log_texts_are_distinct(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки И
+ разные уровни (WARNING vs ERROR), иначе их нельзя различить в логах/алертах."""
+ now = datetime.now(UTC)
+
+ with caplog.at_level("WARNING"):
+ resolve_proxy_url(FakeSession([]), "avito")
+ empty_pool_messages = {rec.levelname: rec.message for rec in caplog.records}
+ caplog.clear()
+
+ with caplog.at_level("WARNING"):
+ with pytest.raises(ProxyPoolExhaustedError):
+ resolve_proxy_url(
+ FakeSession(
+ [_proxy(1)],
+ bans=[
+ {"proxy_id": 1, "source": "avito", "banned_until": now + timedelta(hours=6)}
+ ],
+ ),
+ "avito",
+ )
+ exhausted_messages = {rec.levelname: rec.message for rec in caplog.records}
+
+ assert "ERROR" not in empty_pool_messages
+ assert "ERROR" in exhausted_messages
+ assert empty_pool_messages.get("WARNING") != exhausted_messages.get("ERROR")
+
+
+def test_disabled_proxy_raises_pool_exhausted() -> None:
+ db = FakeSession([_proxy(1, enabled=False)])
+ with pytest.raises(ProxyPoolExhaustedError) as exc_info:
+ resolve_proxy_url(db, "avito")
+ assert exc_info.value.pool_total == 1
+ assert exc_info.value.unhealthy_or_disabled == 1
+ assert exc_info.value.banned_for_source == 0
+
+
+def test_unhealthy_proxy_raises_pool_exhausted() -> None:
+ from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
+
+ db = FakeSession([_proxy(1, consecutive_fails=MAX_CONSECUTIVE_FAILS)])
+ with pytest.raises(ProxyPoolExhaustedError) as exc_info:
+ resolve_proxy_url(db, "avito")
+ assert exc_info.value.unhealthy_or_disabled == 1
+
+
+class _RaisingSession:
+ """db, у которой execute() всегда роняет (DB недоступна) — резолвер не может
+ подтвердить exhaustion, лечит это КАК пустой пул (см. resolve_proxy_url docstring)."""
+
+ def __init__(self) -> None:
+ self.rollback_called = False
+
+ def execute(self, *args: Any, **kwargs: Any) -> Any:
+ raise RuntimeError("connection refused")
+
+ def rollback(self) -> None:
+ self.rollback_called = True
+
+
+def test_db_error_treated_as_empty_pool_falls_back_with_warning(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://static-fallback.local:9999")
+ db = _RaisingSession()
+ with caplog.at_level("WARNING"):
+ result = resolve_proxy_url(db, "avito") # type: ignore[arg-type]
+ assert result == "http://static-fallback.local:9999"
+ assert db.rollback_called
+ assert not any(rec.levelname == "ERROR" for rec in caplog.records)
diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt
index b7aa5c5c..8ccffeea 100644
--- a/tradein-mvp/backend/tests/skip_allowlist.txt
+++ b/tradein-mvp/backend/tests/skip_allowlist.txt
@@ -70,3 +70,12 @@ tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_
# из job-контейнера всё равно не проходит (run 6977, connection refused).
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
+# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
+# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
+# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
+# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на
+# origin/main и зелёным на ветке. Статическая половина файла
+# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах.
+tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
+tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
+tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
index 333da637..29cc9f48 100644
--- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
@@ -81,6 +81,12 @@ _SETTINGS = "app.tasks.avito_detail_backfill.settings"
_SHUTDOWN = "app.tasks.avito_detail_backfill.shutdown_requested"
_BUILD_WARM = "app.tasks.avito_detail_backfill.build_warmed_session"
_RESEARCH = "app.tasks.avito_detail_backfill.research_in_session"
+# #2825: settings.scraper_proxy_url в "elif not use_curl" (legacy curl_cffi) branch
+# заменён на resolve_proxy_url(db, "avito") (пул scrape_proxies с учётом банов,
+# fallback на settings.scraper_proxy_url внутри app.services.proxy_egress) -- эти
+# тесты про block/ban/rotate-логику, не про подбор прокси (см.
+# tests/services/test_proxy_egress.py), поэтому мокаем сам резолвер.
+_RESOLVE_PROXY_URL = "app.tasks.avito_detail_backfill.resolve_proxy_url"
# ---------------------------------------------------------------------------
# Tests
@@ -225,6 +231,7 @@ async def test_backfill_reports_ban_kind_of_the_blocks_it_saw(
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
+ patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, AsyncMock(side_effect=exc_factory())),
patch(_SLEEP, new_callable=AsyncMock),
):
@@ -263,6 +270,7 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None:
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
+ patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
@@ -387,6 +395,7 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None:
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
+ patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
@@ -664,6 +673,7 @@ async def test_backfill_listing_gone_marks_inactive_no_breaker() -> None:
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
+ patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
diff --git a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
index 4df0d729..95ae2c4a 100644
--- a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
@@ -19,7 +19,10 @@ _wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
-from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment # noqa: E402
+from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
+ NewbuildingEnrichment,
+ NewbuildingSaveCounts,
+)
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
NewbuildingEnrichBackfillResult,
@@ -168,24 +171,37 @@ def _enrichment_with_everything(seed: int = 0) -> NewbuildingEnrichment:
def _fake_save_newbuilding_enrichment(db, house_id, enrichment):
- """Stand-in for the real saver: lands price_dynamics + reliability into FakeDB."""
+ """Stand-in for the real saver: lands price_dynamics + reliability into FakeDB.
+
+ Возвращает NewbuildingSaveCounts, как настоящий (#2807): вставку от обновления
+ различает сам писатель — снаружи по таблице их не отличить (UPSERT по dim_key).
+ """
+ inserted = updated = 0
for p in enrichment.realty_valuation_chart:
if p.get("price_per_sqm") is None:
continue
- db.price_dynamics.add(
- (
- house_id,
- p["month_date"],
- "cian_realty_valuation",
- p.get("room_count", "all"),
- p.get("prices_type", "price"),
- p.get("period", "halfYear"),
- )
+ key = (
+ house_id,
+ p["month_date"],
+ "cian_realty_valuation",
+ p.get("room_count", "all"),
+ p.get("prices_type", "price"),
+ p.get("period", "halfYear"),
)
+ if key in db.price_dynamics:
+ updated += 1
+ else:
+ inserted += 1
+ db.price_dynamics.add(key)
+ reliability = 0
for c in enrichment.reliability_checks:
if c.get("check_name") or c.get("check_status"):
db.reliability.append((house_id, "cian_nashdom"))
+ reliability += 1
db.commit()
+ return NewbuildingSaveCounts(
+ price_inserted=inserted, price_updated=updated, reliability_inserted=reliability
+ )
# ---------------------------------------------------------------------------
@@ -279,9 +295,10 @@ async def test_backfill_populates_all_three_tables() -> None:
assert len(db.price_dynamics) == 2 # 1 chart point × 2 houses
assert len(db.reliability) == 2
assert len(db.reviews) == 4 # 2 reviews × 2 houses
- assert result.price_dynamics_rows == 2
- assert result.reliability_rows == 2
- assert result.review_rows == 4
+ assert result.price_dynamics_inserted == 2
+ assert result.price_dynamics_updated == 0
+ assert result.reliability_inserted == 2
+ assert result.review_upserted == 4
@pytest.mark.asyncio
@@ -544,7 +561,7 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk
async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None):
# on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем.
seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec)
- return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2)
+ return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_inserted=2)
monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake_backfill)
monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None)
diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py
index 2d9f320a..d4d34cae 100644
--- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py
@@ -15,6 +15,7 @@ import json
import os
import re
import sys
+from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402
+ CANONICAL_URL_SQL,
+ OFFER_ID_PATTERN,
+ OFFER_URL_PATTERN,
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
@@ -38,7 +42,11 @@ _PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
-_SETTINGS = "app.tasks.yandex_detail_backfill.settings"
+# #2825: settings.scraper_proxy_url заменён на resolve_proxy_url(db, "yandex")
+# (пул scrape_proxies с учётом банов, fallback на settings.scraper_proxy_url внутри
+# app.services.proxy_egress) — эти тесты про loop/parse-логику, не про подбор прокси
+# (см. tests/services/test_proxy_egress.py), поэтому мокаем сам резолвер.
+_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
# ---------------------------------------------------------------------------
# Helpers
@@ -51,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]:
]
-def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
- """Fake Session: execute() отдаёт снапшот через .mappings().all(), а
- .scalar_one() — размер отброшенной (непарсимой) части очереди."""
+def _mock_db(
+ snapshot: list[dict],
+ unenrichable: int = 0,
+ url_from_offer_id: int = 0,
+) -> MagicMock:
+ """Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
+ остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
+ source_id / адресовать нечем)."""
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
- sel.scalar_one.return_value = unenrichable
+ sel.one.return_value = SimpleNamespace(
+ url_from_offer_id=url_from_offer_id,
+ unenrichable_pending=unenrichable,
+ )
db.execute.return_value = sel
return db
@@ -83,10 +99,9 @@ def _make_session_ctx(get_side_effect) -> MagicMock:
return session_cls, session
-def _mock_settings(proxy: str | None = "http://proxy:3128") -> MagicMock:
- s = MagicMock()
- s.scraper_proxy_url = proxy
- return s
+def _mock_resolve_proxy_url(proxy: str | None = "http://proxy:3128") -> MagicMock:
+ """Мок resolve_proxy_url(db, source) -> proxy, независимо от db/source."""
+ return MagicMock(return_value=proxy)
# ---------------------------------------------------------------------------
@@ -104,7 +119,7 @@ async def test_backfill_empty_snapshot_marks_done() -> None:
with (
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db, run_id=1, params={"batch_size": 10, "budget_sec": 60}
@@ -135,7 +150,7 @@ async def test_backfill_processes_snapshot_to_completion() -> None:
patch(_RUNS, runs),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db, run_id=2, params={"batch_size": 10, "budget_sec": 3600}
@@ -168,7 +183,7 @@ async def test_backfill_parse_none_abort_after_max_consecutive() -> None:
patch(_PARSE, return_value=None),
patch(_RUNS, runs),
patch(_SLEEP, new_callable=AsyncMock),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db,
@@ -201,7 +216,7 @@ async def test_backfill_parse_none_resets_on_success() -> None:
patch(_RUNS, runs),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db,
@@ -230,7 +245,7 @@ async def test_backfill_non200_counts_as_fail_and_aborts() -> None:
patch(_PARSE, return_value=MagicMock()),
patch(_RUNS, runs),
patch(_SLEEP, new_callable=AsyncMock),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db,
@@ -259,7 +274,7 @@ async def test_backfill_budget_guard_stops_loop() -> None:
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
patch("app.tasks.yandex_detail_backfill.time.monotonic", side_effect=mono_values),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
await run_yandex_detail_backfill(db, run_id=6, params={"batch_size": 5, "budget_sec": 1})
@@ -276,7 +291,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None:
with (
patch(_RUNS, runs),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
with pytest.raises(RuntimeError, match="DB connection lost"):
await run_yandex_detail_backfill(
@@ -305,7 +320,7 @@ async def test_backfill_fetch_exception_continues() -> None:
patch(_RUNS, runs),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db, run_id=8, params={"batch_size": 10, "budget_sec": 3600}
@@ -336,7 +351,7 @@ async def test_backfill_no_proxy_when_settings_none() -> None:
patch(_RUNS, runs),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
- patch(_SETTINGS, _mock_settings(proxy=None)),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url(proxy=None)),
):
result = await run_yandex_detail_backfill(
db, run_id=9, params={"batch_size": 10, "budget_sec": 3600}
@@ -488,7 +503,7 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
with (
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
- patch(_SETTINGS, _mock_settings()),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db, run_id=42, params={"batch_size": 10, "budget_sec": 60}
@@ -507,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
+
+
+# ---------------------------------------------------------------------------
+# «Непригодно» — ярлык, а не диагноз (2026-08-12)
+# ---------------------------------------------------------------------------
+
+
+def _render_canonical_sql(offer_id: str) -> str:
+ """Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение."""
+ parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")]
+ return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts)
+
+
+def test_recovered_url_equals_producer_canonical_form() -> None:
+ """Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет.
+
+ Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что
+ счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после
+ #2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется
+ только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по
+ собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6).
+
+ Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с
+ `_canonical_source_url` продюсера, а результат — пройти гейт парсера.
+ """
+ from scraper_kit.providers.yandex.serp import _canonical_source_url
+
+ offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru
+ recovered = _render_canonical_sql(offer_id)
+
+ assert re.match(OFFER_ID_PATTERN, offer_id)
+ assert re.search(OFFER_URL_PATTERN, recovered), recovered
+ for stored_url, is_offer_url in _PROD_QUEUE_HEAD:
+ if is_offer_url:
+ continue # у этих сохранённый адрес уже канонический, чинить нечего
+ assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url
+
+
+@pytest.mark.asyncio
+async def test_pending_counter_split_by_reason() -> None:
+ """Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным.
+
+ Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается
+ как «тут делать нечего» — так 3535 квартир простояли неделю вне обогащения.
+ """
+ db = _mock_db([], unenrichable=0, url_from_offer_id=3535)
+ runs = MagicMock()
+ session_cls, _session = _make_session_ctx([])
+
+ with (
+ patch(_ASYNC_SESSION, session_cls),
+ patch(_RUNS, runs),
+ patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
+ ):
+ result = await run_yandex_detail_backfill(
+ db, run_id=43, params={"batch_size": 10, "budget_sec": 60}
+ )
+
+ assert result.url_from_offer_id == 3535
+ assert result.unenrichable_pending == 0
+ counters = runs.mark_done.call_args.args[2]
+ assert counters["url_from_offer_id"] == 3535
+ assert counters["unenrichable_pending"] == 0
+
+ # Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не
+ # выбрасывает: без этой ветки они не попадут в очередь никогда.
+ snapshot_sql = str(db.execute.call_args_list[0].args[0])
+ assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql
+ assert CANONICAL_URL_SQL in snapshot_sql
diff --git a/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
new file mode 100644
index 00000000..9dd3b3b0
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
@@ -0,0 +1,188 @@
+"""#2625: прогон, у которого отказом кончился каждый якорь, перестаёт быть 'done'.
+
+Задача заводилась про капчу Циана и пустые выдачи Яндекса. Основную её часть закрыл
+#2642 (детект провала извлечения структуры → 'banned'). Замер на проде 2026-08-10
+подтверждает эффект симптоматически: развёртки cian/yandex, статус 'done' с нулём —
+42 из 89 до деплоя #2642 (04.08 19:00 UTC) и 1 из 30 после.
+
+Этот единственный оставшийся — прогон 3557 (`yandex_city_sweep_pervouralsk`,
+09.08 17:11): 155 миллисекунд, `{"anchors_total": 1, "anchors_done": 1,
+"errors_count": 1, "lots_fetched": 0}`, статус 'done'. И он не новый: за 90 суток под
+тот же признак попадают 28 прогонов, включая `yandex_city_sweep_nizhniy_tagil` —
+16 суток подряд (15-30.07), каждый ровно 240 секунд (таймаут якоря), ноль лотов,
+'done' каждый раз.
+
+Почему детект #2642 их не видит — и правильно не видит. Он считает попытки РАЗБОРА
+(`_track_gate_result`), а transport_error туда НАМЕРЕННО не попадает (#2600 п.1:
+«наш прокси сдох» не должен выглядеть баном площадки). Якорь, упавший по таймауту
+или исключению до первого разобранного ответа, даёт `attempts == 0`, условие
+`attempts > 0 and failures == attempts` молчит — и прогон уходит в 'done'.
+
+То есть третий исход существовал, но терминального статуса у него не было:
+ * площадка отбила → 'banned' (#2642, есть);
+ * площадка честно пуста → 'done' (есть, и это здоровый ответ);
+ * мы не дошли → ??? → 'done' ← дефект.
+
+Признак намеренно НЕ опирается на список антибот-маркеров: маркеры объясняют уже
+случившийся отказ и молчат про неизвестный (урок 09.08, #2798). Здесь считается
+собственная бухгалтерия прогона — `errors_count >= anchors_total` при измеренном
+нуле. Что она доказывает: каждый якорь кончился отказом и собрано ноль. Чего НЕ
+доказывает: кто виноват. Поэтому 'failed' без ban_kind, а не 'banned'/'platform'.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.orchestration import runs as kit_runs
+
+from app.services import scrape_runs as app_runs
+
+_MODULES = {"kit": kit_runs, "app": app_runs}
+
+# Реальные counters с прода (2026-08-10), не выдуманные.
+PROD_3557_PERVOURALSK = {
+ "anchors_done": 1,
+ "errors_count": 1,
+ "lots_fetched": 0,
+ "lots_updated": 0,
+ "anchors_total": 1,
+ "lots_inserted": 0,
+ "address_failed": 0,
+ "combos_skipped": 0,
+ "address_enriched": 0,
+ "address_attempted": 0,
+ "price_history_rows": 0,
+}
+# Тот же source, прогон 3320 от 06.08 — отказ якоря БЫЛ (errors_count=1), но 117 лотов
+# собрано. Частичный сбор — сбор, статус обязан остаться 'done'.
+PROD_3320_PARTIAL = {**PROD_3557_PERVOURALSK, "lots_fetched": 117, "lots_inserted": 63}
+# Прогон 2930 (cian_city_sweep_serov, 02.08): ноль лотов БЕЗ единого отказа —
+# это либо честная пустота, либо недетект капчи; и то и другое — не наша тема,
+# капча — предмет #2642, а честная пустота обязана оставаться 'done'.
+PROD_2930_HONEST_EMPTY = {**PROD_3557_PERVOURALSK, "errors_count": 0}
+
+
+def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
+ """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов.
+
+ Читаем СТАТУС В SQL, а не имя вызванной функции: тест должен краснеть на
+ поведении финализатора, а не на отсутствии нового имени в старом коде.
+ """
+ statuses: list[str] = []
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ sql = str(stmt)
+ for status in ("done", "failed", "banned"):
+ if f"status = '{status}'" in sql:
+ statuses.append(status)
+ return MagicMock()
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ # sentry заглушаем: алерт-хуки best-effort и к предмету теста отношения не имеют.
+ with patch.object(mod, "sentry_sdk", MagicMock()):
+ mod.mark_done(db, 3557, dict(counters))
+ return statuses
+
+
+# ── 1. Три исхода разведены ──────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_all_anchors_failed_zero_lots_is_not_done(name: str) -> None:
+ """Прод-прогон 3557: все якоря отказали, собрано 0 → 'failed', НЕ 'done'.
+
+ Красный на старом коде: mark_done писал status='done'.
+ """
+ assert _capture_status(_MODULES[name], PROD_3557_PERVOURALSK) == ["failed"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_partial_harvest_stays_done(name: str) -> None:
+ """Прод-прогон 3320: отказ якоря был, но 117 лотов собрано → остаётся 'done'."""
+ assert _capture_status(_MODULES[name], PROD_3320_PARTIAL) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_honest_empty_stays_done(name: str) -> None:
+ """Ноль лотов без единого отказа — честная пустота, 'done' (ложной тревоги нет)."""
+ assert _capture_status(_MODULES[name], PROD_2930_HONEST_EMPTY) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_failed_run_carries_no_ban_diagnosis(name: str) -> None:
+ """Причина не установлена → ban_kind не пишется вовсе (#2764).
+
+ 'banned' с диагнозом означал бы «нас забанила площадка» — а мы знаем только,
+ что якоря отказали. Ротацию IP (#2611) на догадке дёргать нельзя.
+ """
+ mod = _MODULES[name]
+ sqls: list[str] = []
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ sqls.append(str(stmt))
+ return MagicMock()
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ with patch.object(mod, "sentry_sdk", MagicMock()):
+ mod.mark_done(db, 3557, dict(PROD_3557_PERVOURALSK))
+
+ assert not any("ban_kind" in s for s in sqls), "статус не должен нести диагноз"
+ assert any("status = 'failed'" in s for s in sqls)
+
+
+# ── 2. Классификатор: границы, на которых легко получить ложную тревогу ──────
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+@pytest.mark.parametrize(
+ ("counters", "flagged", "why"),
+ [
+ ({"anchors_total": 1, "errors_count": 1, "lots_fetched": 0}, True, "1 из 1 отказал"),
+ ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, True, "5 из 5 (avito ЕКБ)"),
+ ({"anchors_total": 5, "errors_count": 1, "lots_fetched": 0}, False, "1 из 5 — не все"),
+ ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 12}, False, "собрано 12"),
+ ({"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}, False, "честная пустота"),
+ # full-load'ы пишут unique_fetched, а не lots_fetched — тот же смысл.
+ ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 0}, True, "full-load ноль"),
+ ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 340}, False, "full-load сбор"),
+ # Результат НЕ измерен — судить нечем, ноль не выдумывается (#2703).
+ ({"anchors_total": 1, "errors_count": 1}, False, "результата в counters нет"),
+ # Якорей нет вовсе: это не развёртка, чужой словарь счётчиков не трогаем.
+ ({"errors_count": 9, "lots_fetched": 0}, False, "не развёртка"),
+ ({"attempted": 5, "enriched": 0, "failed": 5}, False, "detail-backfill, чужой словарь"),
+ ({}, False, "пустые counters"),
+ ({"anchors_total": 0, "errors_count": 0, "lots_fetched": 0}, False, "нуль якорей"),
+ ],
+)
+def test_classifier_boundaries(
+ name: str, counters: dict[str, Any], flagged: bool, why: str
+) -> None:
+ reason = _MODULES[name]._sweep_run_did_nothing(counters)
+ assert (reason is not None) is flagged, why
+ if flagged:
+ assert "#2625" in (reason or "")
+
+
+# ── 3. Ровно те 16 суток, что прод прожил молча ──────────────────────────────
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_sixteen_silent_tagil_runs_would_have_been_failed(name: str) -> None:
+ """15-30.07: 16 прогонов `yandex_city_sweep_nizhniy_tagil` по 240 с, 0 лотов.
+
+ Каждый отчитался 'done'. Ни один алерт их не поднял: `_alert_if_consecutive_failures`
+ считает только failed/banned. С этой правкой все 16 — 'failed', то есть первый же
+ из них попадает в лестницу вех failed-стрика (#2670).
+ """
+ tagil = {"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0}
+ statuses = [_capture_status(_MODULES[name], tagil) for _ in range(16)]
+ assert statuses == [["failed"]] * 16
diff --git a/tradein-mvp/backend/tests/test_2670_stale_source_digest.py b/tradein-mvp/backend/tests/test_2670_stale_source_digest.py
new file mode 100644
index 00000000..baf2ec4a
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2670_stale_source_digest.py
@@ -0,0 +1,199 @@
+"""#2670 (остаток): лестница напоминаний не отвечает на вопрос «что сломано сейчас».
+
+#2720 вылечил «алерт ровно один раз за серию»: теперь вехи 3, 6, 12, 24, 48… Но лестница
+шагает по ПОДРЯД ИДУЩИМ завершённым failed/banned прогонам, а на проде 2026-08-10 три
+самых залежавшихся источника из шести просроченных ей недоступны — и лишь один из трёх
+из-за редких вех:
+
+ ┌────────────────────────────┬────────┬───────┬──────────────────────────────────────┐
+ │ источник │ стрик │ сут. │ когда напомнит лестница │
+ ├────────────────────────────┼────────┼───────┼──────────────────────────────────────┤
+ │ avito_full_load_exhaustive │ 0 │ 49.5 │ никогда: 5 банов обнулил 'cancelled' │
+ │ cian_history_backfill │ 0 │ 42.1 │ никогда: прогонов нет с 30.06 │
+ │ avito_full_load │ 31 │ 37.7 │ веха 48 → +17 прогонов × 7 сут = 119 │
+ │ avito_detail_backfill │ 5 │ 5.2 │ веха 6 → завтра │
+ │ domclick_city_sweep │ 5 │ 5.1 │ веха 6 → завтра │
+ │ domclick_detail_backfill │ 4 │ 5.0 │ веха 6 → послезавтра │
+ └────────────────────────────┴────────┴───────┴──────────────────────────────────────┘
+
+Уплотнение вех (3,4,5,6…) чинит ТОЛЬКО третью строку: у первых двух стрик равен нулю,
+уплотнять нечего — «замолчал» там означает «перестал производить прогоны», а не «серия
+длиннее последней вехи». Поэтому остаток задачи закрывает сводка, считающая КАЛЕНДАРНЫЙ
+возраст последнего успеха, а не длину серии.
+
+Фальсификация: на коде до этой правки `emit_stale_digest`/`stale_sources` не существует
+(ImportError на сборе тестов) — сводки нет ни в каком виде. Тест
+`test_ladder_is_silent_for_the_worst_two` — КОНТРОЛЬ: он зелёный и до, и после правки и
+показывает ровно то, чего сводка не заменяет, а добавляет: лестница на этих двух молчит.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from scraper_kit.orchestration import runs as kit_runs
+from scraper_kit.orchestration import scheduler as sched
+
+NOW = datetime(2026, 8, 10, 8, 0, tzinfo=UTC)
+
+
+def _row(source: str, interval_days: Any, age_days: float, never_ok: bool = False) -> Any:
+ """Строка `_STALE_SOURCES_SQL`: last_ok уже схлопнут в `since` через COALESCE."""
+ return SimpleNamespace(
+ source=source,
+ interval_days=interval_days,
+ since=NOW - timedelta(days=age_days),
+ never_ok=never_ok,
+ )
+
+
+# Снимок прода 2026-08-10 08:00 UTC: все 52 включённых расписания не влезают, взяты все
+# просроченные + четыре контрольных, каждое из которых мимо порога по своей причине.
+PROD_ROWS = [
+ _row("cian_history_backfill", None, 42.1), # такт по умолчанию (daily)
+ _row("avito_full_load_exhaustive", 7, 49.5),
+ _row("avito_full_load", 7, 37.7),
+ _row("avito_detail_backfill", None, 5.2),
+ _row("domclick_city_sweep", None, 5.1),
+ _row("domclick_detail_backfill", None, 5.0),
+ # ── контроль: НЕ просрочены ──
+ _row("rosreestr_quarter_poll", 28, 24.0), # 24 сут при такте 28 — норма
+ _row("sber_index_pull", 7, 4.1),
+ _row("avito_city_sweep", None, 1.1),
+ _row("proxy_healthcheck", None, 0.02),
+]
+
+# Порядок — по числу ПРОПУЩЕННЫХ ТАКТОВ (age/interval), а не по календарю: 42 суток
+# у суточного backfill'а = 42 пропущенных такта, 49.5 у недельного = 7.
+PROD_STALE = [
+ "cian_history_backfill", # 42.1 / 1
+ "avito_full_load_exhaustive", # 49.5 / 7 = 7.07
+ "avito_full_load", # 37.7 / 7 = 5.39
+ "avito_detail_backfill", # 5.2 / 1
+ "domclick_city_sweep", # 5.1 / 1
+ "domclick_detail_backfill", # 5.0 / 1
+]
+
+
+@pytest.fixture(autouse=True)
+def _reset_digest_clock() -> Any:
+ """Выпуск сводки помнится в памяти модуля — сбрасываем между тестами."""
+ sched._last_stale_digest_at = None
+ yield
+ sched._last_stale_digest_at = None
+
+
+def _db(rows: list[Any]) -> MagicMock:
+ db = MagicMock()
+ db.execute.return_value.fetchall.return_value = rows
+ return db
+
+
+# ── 1. Чистая логика порога ──────────────────────────────────────────────────
+
+
+def test_stale_sources_names_exactly_the_prod_six() -> None:
+ """Шесть просроченных из десяти, порядок — по числу пропущенных ТАКТОВ, не суток."""
+ stale = sched.stale_sources(PROD_ROWS, NOW)
+ assert [s.source for s in stale] == PROD_STALE
+
+
+def test_quarterly_source_is_not_stale_at_24_days() -> None:
+ """Порог считается в тактах: 24 сут для 28-суточного poll'а — не просрочка."""
+ assert sched.stale_sources([_row("rosreestr_quarter_poll", 28, 24.0)], NOW) == []
+ # …а 85 суток (>3×28) — уже просрочка.
+ assert [s.source for s in sched.stale_sources([_row("q", 28, 85.0)], NOW)] == ["q"]
+
+
+@pytest.mark.parametrize("raw", [None, "null", "", "abc", 0, -5])
+def test_broken_interval_falls_back_to_daily(raw: Any) -> None:
+ """`interval_days: null` и мусор → такт 1 сут, как у compute_next_run_at."""
+ assert sched._schedule_interval_days(raw) == 1
+
+
+def test_never_successful_source_is_reported_with_a_flag() -> None:
+ """Расписание без единого 'done' считается от created_at и помечается явно."""
+ (only,) = sched.stale_sources([_row("brand_new", 1, 9.0, never_ok=True)], NOW)
+ assert only.never_ok is True
+
+
+# ── 2. Выпуск сводки ─────────────────────────────────────────────────────────
+
+
+def test_digest_emits_one_event_listing_all_stale_sources() -> None:
+ sentry = MagicMock()
+ with patch.object(sched, "sentry_sdk", sentry):
+ stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
+ assert [s.source for s in stale] == PROD_STALE
+ sentry.capture_message.assert_called_once()
+ msg = sentry.capture_message.call_args[0][0]
+ assert msg.startswith("6 scraper sources are stale")
+ for name in PROD_STALE:
+ assert name in msg
+ assert "rosreestr_quarter_poll" not in msg
+
+
+def test_digest_covers_the_two_sources_the_ladder_cannot_reach() -> None:
+ """Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию."""
+ sentry = MagicMock()
+ with patch.object(sched, "sentry_sdk", sentry):
+ stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
+ zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"}
+ assert zero_streak <= {s.source for s in stale}
+
+
+def test_digest_is_quiet_when_everything_is_fresh() -> None:
+ sentry = MagicMock()
+ fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
+ with patch.object(sched, "sentry_sdk", sentry):
+ assert sched.emit_stale_digest(_db(fresh), now=NOW) == []
+ sentry.capture_message.assert_not_called()
+
+
+def test_digest_is_daily_not_per_tick() -> None:
+ """Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
+ sentry = MagicMock()
+ db = _db(PROD_ROWS)
+ with patch.object(sched, "sentry_sdk", sentry):
+ sched.emit_stale_digest(db, now=NOW)
+ sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))
+ sched.emit_stale_digest(db, now=NOW + timedelta(hours=23))
+ assert sentry.capture_message.call_count == 1
+ sched.emit_stale_digest(db, now=NOW + timedelta(hours=24, minutes=1))
+ assert sentry.capture_message.call_count == 2
+
+
+def test_digest_failure_never_breaks_the_tick() -> None:
+ """Сводка — best-effort: упавший запрос не имеет права уронить тик планировщика."""
+ db = MagicMock()
+ db.execute.side_effect = RuntimeError("db down")
+ with patch.object(sched, "sentry_sdk", MagicMock()):
+ assert sched.emit_stale_digest(db, now=NOW) == []
+
+
+# ── 3. Контроль: что именно сводка ДОБАВЛЯЕТ к лестнице ──────────────────────
+
+
+@pytest.mark.parametrize(
+ ("name", "streak"),
+ [("avito_full_load_exhaustive", 0), ("cian_history_backfill", 0), ("avito_full_load", 31)],
+)
+def test_ladder_is_silent_for_the_worst_two(name: str, streak: int) -> None:
+ """КОНТРОЛЬ (зелёный и до правки): у трёх худших источников лестница молчит.
+
+ Стрик 0 — прогонов нет / серию обнулил 'cancelled'; стрик 31 — между вехами 24 и 48.
+ """
+ rows = [SimpleNamespace(status="banned") for _ in range(streak)]
+ rows += [SimpleNamespace(status="done") for _ in range(3)]
+ sentry = MagicMock()
+ with patch.object(kit_runs, "sentry_sdk", sentry):
+ kit_runs._alert_if_consecutive_failures(_db(rows), name)
+ sentry.capture_message.assert_not_called()
diff --git a/tradein-mvp/backend/tests/test_2687_pool_exhaustion_keeps_checkpoint.py b/tradein-mvp/backend/tests/test_2687_pool_exhaustion_keeps_checkpoint.py
new file mode 100644
index 00000000..6dd54899
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2687_pool_exhaustion_keeps_checkpoint.py
@@ -0,0 +1,166 @@
+"""#2687: опустевший пул прокси mid-run стирал чекпоинт полного обхода.
+
+Найдено при разборе шести `ban_kind='infra'` у `avito_full_load` (29.07-03.08). Сами
+шесть объяснены и починены раньше (#2637 подключил браузерный путь Авито к пулу,
+#2634/#2640 добили запасной путь), но рядом с ними на main живёт соседний дефект того
+же класса, что и #2686: наш собственный отказ финализируется веткой, которая теряет
+чекпоинт.
+
+Механизм. `NoProxyAvailableError` (proxy_errors.py) — наследник `RuntimeError`, и его
+собственный докстринг говорит прямо: «это НАША инфраструктура (нет живого прокси), не
+внешний блок». В `run_*_full_load` он попадал в общую ветку `except RuntimeError` →
+`mark_failed`, а `mark_failed`, в отличие от `mark_banned`, НЕ пишет `done_buckets`
+(pipeline.py, ветка блока пишет его явно). Плюс `ban_kind_of_exception` возвращал для
+него `unknown` — диагноз «наша инфраструктура» терялся дважды: и как метка, и как
+сохранённый прогресс.
+
+Достижимость на проде (замер 2026-08-12, read-only):
+ - под `avito` доступны три узла (id 9/10/11; id 1 забанен парой до 13.08);
+ - `BrowserFetcher` меняет lease после `_LEASE_ROTATE_AFTER_FAILS=3` подряд неудачных
+ `/fetch`, а `proxy_pool.acquire` не выдаёт узел с
+ `consecutive_fails >= MAX_CONSECUTIVE_FAILS=3` → девять неудачных POST'ов
+ опустошают пул под источник;
+ - лестница ОДНОГО полного обхода при отказывающем сайдкаре делает до 24 POST'ов
+ (4 бакета `_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED` × 3 попытки
+ `_AVITO_SIDECAR_TRANSIENT_RETRIES`+1 × 2 внутренних httpx-ретрая `fetch`),
+ то есть заведомо проходит через это состояние.
+Цена потери чекпоинта — прогон 3547 (09.08): 35 бакетов, 5496 объявлений за 2ч58м.
+
+Фальсификация (красный прогон на коде до правки):
+ - `ban_kind_of_exception` → `'unknown'` вместо `'infra'`;
+ - `run_*_full_load` → `mark_failed` вместо `mark_banned`, исключение улетает наружу
+ (тест падает на непойманном `NoProxyAvailableError`), `done_buckets` нигде нет.
+"""
+
+from __future__ import annotations
+
+import os
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from scraper_kit.orchestration import runs as kit_runs
+from scraper_kit.orchestration.pipeline import (
+ ban_kind_of_exception,
+ run_avito_full_load,
+ run_cian_full_load,
+ run_yandex_full_load,
+)
+from scraper_kit.proxy_errors import NoProxyAvailableError
+
+PFX = "scraper_kit.orchestration.pipeline"
+
+_FULL_LOAD = {
+ "avito": (run_avito_full_load, f"{PFX}.AvitoScraper"),
+ "cian": (run_cian_full_load, f"{PFX}.CianScraper"),
+ "yandex": (run_yandex_full_load, f"{PFX}.YandexRealtyScraper"),
+}
+
+
+class _Recorder:
+ """Двойник scrape_runs: интересны имя финализатора, ban_kind и counters."""
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, str, dict[str, Any]]] = []
+
+ def is_cancelled(self, db: Any, run_id: int) -> bool:
+ return False
+
+ def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
+ pass
+
+ def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
+ self.calls.append(("mark_done", "", dict(counters)))
+
+ def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
+ self.calls.append(("mark_failed", "", dict(counters)))
+
+ def mark_banned(
+ self,
+ db: Any,
+ run_id: int,
+ error: str,
+ counters: dict[str, Any],
+ *,
+ ban_kind: str = kit_runs.BAN_KIND_UNKNOWN,
+ ) -> None:
+ self.calls.append(("mark_banned", ban_kind, dict(counters)))
+
+
+def _config() -> SimpleNamespace:
+ return SimpleNamespace(
+ scraper_fetch_mode="curl_cffi",
+ browser_http_endpoint="http://browser.test/fetch",
+ scraper_proxy_url=None,
+ avito_proxy_max_rotations=0,
+ avito_serp_ok_not_banned=True,
+ avito_proxy_rotate_settle_s=0.0,
+ proxy_rotate_attempts=1,
+ proxy_rotate_attempt_timeout_s=1.0,
+ cian_proxy_max_rotations=0,
+ yandex_proxy_max_rotations=0,
+ cian_full_load_per_fetch_timeout_s=0.0,
+ scraper_skip_seen_today=False,
+ )
+
+
+def _scraper_that_saves_one_bucket_then_runs_out_of_proxies() -> MagicMock:
+ """Двойник скрапера: один бакет доехал до on_bucket, затем пул опустел."""
+
+ async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
+ on_bucket("2к:0-5m", [MagicMock(source_id="a1")])
+ raise NoProxyAvailableError("avito")
+
+ m = MagicMock()
+ m.__aenter__ = AsyncMock(return_value=m)
+ m.__aexit__ = AsyncMock(return_value=None)
+ m.fetch_all_secondary = _fetch
+ m.state_extraction_attempts = 0
+ m.state_extraction_failures = 0
+ m.gate_fetch_attempts = 0
+ m.gate_fetch_failures = 0
+ m._browser = None
+ return m
+
+
+def test_ban_kind_of_no_proxy_is_infra() -> None:
+ """Пустой пул — наша инфраструктура по определению самого исключения.
+
+ Раньше классификатор отдавал 'unknown' (дефолт #2764): для НЕизвестной причины
+ это верно, но здесь причина известна и названа в типе.
+ """
+ assert ban_kind_of_exception(NoProxyAvailableError("avito")) == kit_runs.BAN_KIND_INFRA
+
+
+@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
+async def test_full_load_pool_exhaustion_keeps_checkpoint(source: str) -> None:
+ """Пул опустел mid-run → 'banned'/'infra' и done_buckets целы, а не mark_failed.
+
+ Требование #2686 («чекпоинт сохраняется и при нашем сбое, и при блокировке
+ площадкой») до этой правки держалось только для отказа сайдкара; отказ пула шёл
+ мимо него и обнулял прогресс следующего прогона.
+ """
+ fn, scraper_target = _FULL_LOAD[source]
+ recorder = _Recorder()
+ extra: dict[str, Any] = {}
+ if source == "yandex":
+ enrichment = MagicMock()
+ enrichment.record_yandex_price_history = MagicMock(return_value=0)
+ extra["enrichment"] = enrichment
+
+ scraper = _scraper_that_saves_one_bucket_then_runs_out_of_proxies()
+ with (
+ patch(scraper_target, return_value=scraper),
+ patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
+ patch(f"{PFX}.runs", recorder),
+ ):
+ await fn(MagicMock(), run_id=1, config=_config(), matcher=MagicMock(), **extra)
+
+ assert [c[0] for c in recorder.calls] == ["mark_banned"]
+ _method, ban_kind, counters = recorder.calls[0]
+ assert ban_kind == kit_runs.BAN_KIND_INFRA
+ assert counters["done_buckets"] == ["2к:0-5m"]
diff --git a/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
new file mode 100644
index 00000000..d6637486
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2700_cian_detail_403_node.py
@@ -0,0 +1,208 @@
+"""#2700: 403 Циана перестаёт умирать внутри провайдера — узел снимается, прогон честен.
+
+Живая различающая проба на проде 2026-08-10 (один и тот же detail-URL, один и тот же
+код, менялся ТОЛЬКО прокси-узел):
+
+ узел 1 (asocks-residential-1, 46.8.110.92) → HTTP 403, 21 564 б, `cian_waf_block`
+ узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 б, state ok
+ узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 б, state ok
+ узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 б, state ok
+
+То есть отбита была ПАРА «узел × Циан», а не площадка (и не наши заголовки: те же 200
+пришли без единой куки — detail-страница авторизации не требует). Пятнадцать суток
+подряд это выглядело как «Циан нас забанил» ровно потому, что 403 гасился в
+`return None`: пул получал `mark_health(ok=True)` на отбитый узел и продолжал выдавать
+его Циану, а прогон писал `detail_failed=50` при `errors_count=0` и статусе `done`.
+
+Тесты красные на старом коде:
+ * `fetch_detail` возвращал None и НЕ звал `mark_banned` → узел оставался в выдаче;
+ * `mark_done` писал `status='done'` прогону, у которого отказали все 50 попыток.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.cian_exceptions import CianBlockedError
+from scraper_kit.contracts import ProxyLease
+from scraper_kit.orchestration import runs as kit_runs
+from scraper_kit.providers.cian import detail as cian_detail
+from scraper_kit.proxy_errors import ProxyBanError
+
+from app.services import scrape_runs as app_runs
+
+_MODULES = {"kit": kit_runs, "app": app_runs}
+_LEASE = ProxyLease(id=1, url="http://user:pass@node-1:10423", kind="http", rotate_url=None)
+
+
+@dataclass
+class _FakeConfig:
+ use_proxy_pool_curl: bool = True
+ cian_proxy_url: str | None = None
+ environment: str = "production"
+
+
+class _SpyProvider:
+ """ProxyProvider-заглушка (тот же контракт, что в test_proxy_pool_curl_paths)."""
+
+ def __init__(self) -> None:
+ self.mark_health_calls: list[tuple[int, bool]] = []
+ self.mark_banned_calls: list[tuple[int, str]] = []
+ self.release_calls: list[int] = []
+
+ def acquire(self, provider: str) -> ProxyLease:
+ return _LEASE
+
+ def release(self, lease: ProxyLease) -> None:
+ self.release_calls.append(lease.id)
+
+ def mark_health(
+ self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
+ ) -> None:
+ self.mark_health_calls.append((lease.id, ok))
+
+ def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
+ self.mark_banned_calls.append((lease.id, source))
+
+
+def _session_returning(status_code: int, text: str = "") -> MagicMock:
+ session = MagicMock()
+ session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
+ session.close = AsyncMock()
+ return session
+
+
+async def _fetch(status_code: int, spy: _SpyProvider) -> Any:
+ with patch.object(
+ cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
+ ):
+ return await cian_detail.fetch_detail(
+ "https://ekb.cian.ru/sale/flat/332775238/",
+ config=_FakeConfig(),
+ proxy_provider=spy,
+ )
+
+
+# ── 1. 403 доходит до пула ────────────────────────────────────────────────────
+
+
+async def test_403_bans_the_node_for_cian_only() -> None:
+ """Красный на старом коде: было `return None`, бана узла не происходило.
+
+ Проверяется ПОВЕДЕНИЕ пула (`mark_banned` на паре «узел × cian»), а не наличие
+ нового имени в коде.
+ """
+ spy = _SpyProvider()
+ with pytest.raises(CianBlockedError):
+ await _fetch(403, spy)
+ assert spy.mark_banned_calls == [(1, "cian")]
+ assert spy.mark_health_calls == [(1, False)]
+ assert spy.release_calls == [1] # lease не течёт даже на бане
+
+
+def test_blocked_error_is_recognised_by_generic_proxy_layer() -> None:
+ """Generic curl-слой узнаёт бан по `ProxyBanError`, не зная про Циан."""
+ assert issubclass(CianBlockedError, ProxyBanError)
+
+
+# ── 2. Не-бан остаётся не-баном ───────────────────────────────────────────────
+
+
+async def test_404_does_not_ban_the_node() -> None:
+ """Снятое объявление — не бан: наказывать за него здоровый узел нельзя."""
+ spy = _SpyProvider()
+ assert await _fetch(404, spy) is None
+ assert spy.mark_banned_calls == []
+ assert spy.mark_health_calls == [(1, True)]
+
+
+# ── 3. Прогон с полностью отказавшей фазой перестаёт быть 'done' ──────────────
+
+
+def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
+ """Статусы всех UPDATE'ов, которые сделал mark_done на фейковой сессии.
+
+ Читаем СТАТУС В SQL (как в test_2625_run_that_did_nothing), а не имя вызванной
+ функции: тест обязан краснеть на поведении финализатора.
+ """
+ statuses: list[str] = []
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ sql = str(stmt)
+ for status in ("done", "failed", "banned"):
+ if f"status = '{status}'" in sql:
+ statuses.append(status)
+ return MagicMock()
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ with patch.object(mod, "sentry_sdk", MagicMock()):
+ mod.mark_done(db, 3258, dict(counters))
+ return statuses
+
+
+# Реальные counters с прода, не выдуманные.
+# Прогон 3258 (cian_city_sweep, 06.08): 50 из 50 detail'ов отказали, статус 'done'.
+PROD_3258_ALL_DETAIL_FAILED = {
+ "anchors_done": 5,
+ "anchors_total": 5,
+ "errors_count": 0,
+ "lots_fetched": 1680,
+ "lots_inserted": 59,
+ "lots_updated": 626,
+ "detail_attempted": 50,
+ "detail_failed": 50,
+ "detail_enriched": 0,
+ "houses_attempted": 40,
+ "houses_failed": 40,
+ "houses_enriched": 0,
+}
+# Прогон 3597 (cian_city_sweep, 10.08, уже после того как 403 ушёл): 10 из 11 удачны.
+PROD_3597_MOSTLY_OK = {
+ **PROD_3258_ALL_DETAIL_FAILED,
+ "errors_count": 1,
+ "detail_attempted": 11,
+ "detail_failed": 1,
+ "detail_enriched": 10,
+ "houses_attempted": 0,
+ "houses_failed": 0,
+}
+# Одиночная попытка, и та неудачна — шум, а не диагноз: прогон остаётся 'done'.
+SINGLE_ATTEMPT_FAILED = {
+ **PROD_3597_MOSTLY_OK,
+ "detail_attempted": 1,
+ "detail_failed": 1,
+ "detail_enriched": 0,
+}
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_phase_failed_100_percent_is_not_done(name: str) -> None:
+ """Прод-прогон 3258: detail 50/50 отказ → 'failed'. Красный на старом коде."""
+ assert _capture_status(_MODULES[name], PROD_3258_ALL_DETAIL_FAILED) == ["failed"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_partial_phase_failure_stays_done(name: str) -> None:
+ """Прод-прогон 3597: 1 отказ из 11 → остаётся 'done' (частичный отказ — не отказ)."""
+ assert _capture_status(_MODULES[name], PROD_3597_MOSTLY_OK) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_single_failed_attempt_stays_done(name: str) -> None:
+ """Порог _PHASE_MIN_ATTEMPTS: одна неудачная попытка прогон не роняет."""
+ assert _capture_status(_MODULES[name], SINGLE_ATTEMPT_FAILED) == ["done"]
+
+
+@pytest.mark.parametrize("name", list(_MODULES))
+def test_zero_attempts_stays_done(name: str) -> None:
+ """Фаза не запускалась (0 попыток) — 0 == 0 не должно читаться как отказ."""
+ counters = {**PROD_3597_MOSTLY_OK, "detail_attempted": 0, "detail_failed": 0}
+ assert _capture_status(_MODULES[name], counters) == ["done"]
diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
index 81056835..fea61b82 100644
--- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
+++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
@@ -185,7 +185,7 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(
- processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_rows=7
+ processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7
),
)
calls = _stub_finalisers(monkeypatch)
@@ -196,5 +196,5 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
assert counters["attempted"] == 10
assert counters["enriched"] == 3
assert counters["failed"] == 7
- assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте
+ assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте
assert counters["succeeded"] == 3
diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
new file mode 100644
index 00000000..69b93713
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
@@ -0,0 +1,193 @@
+"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали».
+
+`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после
+сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому
+обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался
+`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые,
+что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался
+как «динамика цен снова не пишется».
+
+Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял
+`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows`
+игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов.
+
+Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates`
+даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом.
+
+Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на
+`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон,
+который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from unittest.mock import MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+sys.modules.setdefault("weasyprint", MagicMock())
+
+from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
+ NewbuildingSaveCounts,
+ save_newbuilding_enrichment,
+)
+
+from app.services import scrape_runs as runs_mod # noqa: E402
+from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
+ NewbuildingEnrichBackfillResult,
+)
+
+# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в
+# тесте совпадало с числом в задаче.
+PROD_POINTS = 64
+
+
+class _UpsertDB:
+ """Сессия, у которой houses_price_dynamics уже населена (второй проход).
+
+ `RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили».
+ """
+
+ def __init__(self, *, already_present: bool) -> None:
+ self.already_present = already_present
+ self.price_writes = 0
+ self.reliability_writes = 0
+ self.committed = False
+
+ def execute(self, statement, params=None):
+ sql = str(statement)
+ res = MagicMock()
+ if "INSERT INTO houses_price_dynamics" in sql:
+ self.price_writes += 1
+ assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update"
+ res.fetchone.return_value = (not self.already_present,)
+ return res
+ if "INSERT INTO house_reliability_checks" in sql:
+ self.reliability_writes += 1
+ res.fetchone.return_value = None
+ return res
+
+ def commit(self) -> None:
+ self.committed = True
+
+
+def _enrichment(points: int):
+ from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment
+
+ return NewbuildingEnrichment(
+ cian_internal_house_id=1,
+ cian_zhk_url="https://zhk-x.cian.ru/",
+ name="ЖК Тест",
+ realty_valuation_chart=[
+ {
+ "month_date": f"2026-{(i % 12) + 1:02d}-01",
+ "room_count": "all",
+ "prices_type": "price",
+ "period": "halfYear",
+ "price_per_sqm": 150000.0 + i,
+ }
+ for i in range(points)
+ ],
+ reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}],
+ reviews=[],
+ )
+
+
+# ── 1. Писатель различает вставку и обновление ───────────────────────────────
+
+
+def test_first_pass_reports_inserts() -> None:
+ db = _UpsertDB(already_present=False)
+ counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
+ assert counts.price_inserted == PROD_POINTS
+ assert counts.price_updated == 0
+ assert counts.reliability_inserted == 1
+ assert db.price_writes == PROD_POINTS
+
+
+def test_second_pass_reports_updates() -> None:
+ """Прод-симптом: те же 64 точки, ничего нового — но записаны все 64.
+
+ До правки этот прогон отчитывался нулём по всем трём счётчикам.
+ """
+ db = _UpsertDB(already_present=True)
+ counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
+ assert counts.price_inserted == 0
+ assert counts.price_updated == PROD_POINTS
+ assert counts.price_written == PROD_POINTS
+ assert db.price_writes == PROD_POINTS
+
+
+def test_nothing_to_write_stays_zero() -> None:
+ """Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено»."""
+ db = _UpsertDB(already_present=True)
+ counts = save_newbuilding_enrichment(db, 42, _enrichment(0))
+ assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0)
+
+
+def test_points_without_price_are_not_counted_as_written() -> None:
+ """Точка без price_per_sqm пропускается писателем — и не попадает в счёт."""
+ enrichment = _enrichment(2)
+ enrichment.realty_valuation_chart[0]["price_per_sqm"] = None
+ db = _UpsertDB(already_present=False)
+ counts = save_newbuilding_enrichment(db, 42, enrichment)
+ assert counts.price_written == 1
+ assert db.price_writes == 1
+
+
+# ── 2. Сторож нулевого результата не ослаблен ────────────────────────────────
+
+
+def _finalize(counters: dict[str, int]) -> str:
+ """Прогнать counters через боевой финализатор и вернуть выбранный статус."""
+ chosen: dict[str, str] = {}
+ with (
+ patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")),
+ patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")),
+ patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")),
+ ):
+ runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich")
+ return chosen["s"]
+
+
+def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None:
+ """Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали.
+
+ Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched.
+ Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех.
+ """
+ result = NewbuildingEnrichBackfillResult(
+ processed=25,
+ succeeded=0,
+ failed_fetch=25,
+ price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили
+ )
+ assert _finalize(result.to_backfill_counters()) == "failed"
+
+
+def test_watchdog_verdict_ignores_the_new_keys() -> None:
+ """Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону."""
+ base = {"attempted": 25, "enriched": 3, "failed": 22}
+ assert _finalize(dict(base)) == "done"
+ assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done"
+ zero = {"attempted": 25, "enriched": 0, "failed": 25}
+ assert _finalize(dict(zero)) == "failed"
+ assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed"
+
+
+def test_counters_carry_both_numbers_into_the_run() -> None:
+ """В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль."""
+ counters = NewbuildingEnrichBackfillResult(
+ processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS
+ ).to_backfill_counters()
+ assert counters["price_dynamics_inserted"] == 0
+ assert counters["price_dynamics_updated"] == PROD_POINTS
+ # Старые имена не должны остаться: у них в истории прогонов другой смысл.
+ assert "price_dynamics_rows" not in counters
+ assert "reliability_rows" not in counters
+ assert "review_rows" not in counters
+
+
+def test_save_counts_written_is_the_sum() -> None:
+ assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7
diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
new file mode 100644
index 00000000..a6fb3820
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
@@ -0,0 +1,340 @@
+"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси.
+
+Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел,
+общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места
+продолжали брать egress именно оттуда:
+
+ 1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history);
+ 2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный
+ узел, пока трафик уже выбирался пулом (#2825/#2831);
+ 3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул
+ только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`.
+
+Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие
+kwarg'а в вызове. Красные на старом коде:
+
+ * (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не
+ сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
+ `USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
+ где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
+ * (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
+ параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
+ выражения не имел вообще.
+ * (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал
+ `mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821).
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.cian_exceptions import CianBlockedError
+from scraper_kit.contracts import ProxyLease
+from scraper_kit.providers.cian import detail as cian_detail
+from scraper_kit.providers.cian import newbuilding as cian_newbuilding
+from scraper_kit.proxy_errors import NoProxyAvailableError
+
+# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный
+# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1).
+_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None)
+
+
+class _SpyProvider:
+ """ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу."""
+
+ def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
+ self._lease = lease
+ self.acquire_calls: list[str] = []
+ self.mark_health_calls: list[tuple[int, bool]] = []
+ self.mark_banned_calls: list[tuple[int, str]] = []
+ self.release_calls: list[int] = []
+
+ def acquire(self, provider: str) -> ProxyLease | None:
+ self.acquire_calls.append(provider)
+ return self._lease
+
+ def release(self, lease: ProxyLease) -> None:
+ self.release_calls.append(lease.id)
+
+ def mark_health(
+ self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
+ ) -> None:
+ self.mark_health_calls.append((lease.id, ok))
+
+ def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
+ self.mark_banned_calls.append((lease.id, source))
+
+
+class _SpyPool:
+ """Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`.
+
+ Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового
+ имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит.
+ """
+
+ def __init__(self, lease: Any = _LEASE) -> None:
+ self._lease = lease
+ self.acquire_calls: list[str] = []
+ self.mark_health_calls: list[tuple[int, bool]] = []
+ self.mark_banned_calls: list[tuple[int, str]] = []
+ self.release_calls: list[int] = []
+
+ def acquire(self, db: Any, provider: str, **kw: Any) -> Any:
+ self.acquire_calls.append(provider)
+ return self._lease
+
+ def release(self, db: Any, lease_id: int) -> None:
+ self.release_calls.append(lease_id)
+
+ def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None:
+ self.mark_health_calls.append((lease_id, ok))
+
+ def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None:
+ self.mark_banned_calls.append((lease_id, source))
+
+
+def _session_returning(status_code: int, text: str = "") -> MagicMock:
+ session = MagicMock()
+ session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
+ session.close = AsyncMock()
+ return session
+
+
+# ── 1. cian_price_history: узел из пула + вердикт обратно ────────────────────
+
+
+def _price_history_db(n_listings: int) -> MagicMock:
+ """Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт)."""
+ rows = [
+ {"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"}
+ for i in range(n_listings)
+ ]
+ listings_result = MagicMock()
+ listings_result.mappings.return_value.all.return_value = rows
+ db = MagicMock()
+ db.execute.return_value = listings_result
+ return db
+
+
+async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any:
+ from app.services import scraper_adapters
+ from app.services.cian_price_history import backfill_cian_price_history
+
+ with (
+ patch.object(
+ cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
+ ),
+ patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
+ patch.object(scraper_adapters, "_proxy_pool", pool),
+ patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
+ ):
+ return await backfill_cian_price_history(_price_history_db(n), batch_size=n)
+
+
+@pytest.mark.asyncio
+async def test_price_history_takes_pool_node_despite_flag_off() -> None:
+ """Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
+
+ Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
+ pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
+ """
+ from app.core.config import settings
+
+ assert (
+ settings.use_proxy_pool_curl is False
+ ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
+ pool = _SpyPool()
+ await _run_price_history(pool, status_code=200)
+ assert pool.acquire_calls == ["cian"]
+ assert pool.release_calls == [9] # lease не течёт
+
+
+@pytest.mark.asyncio
+async def test_price_history_403_bans_the_node_for_cian() -> None:
+ """403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""
+ pool = _SpyPool()
+ result = await _run_price_history(pool, status_code=403)
+ assert pool.mark_banned_calls == [(9, "cian")]
+ assert pool.mark_health_calls == [(9, False)]
+ assert result.errors == 1 # прогон честен: отказ посчитан
+
+
+@pytest.mark.asyncio
+async def test_price_history_404_does_not_ban_the_node() -> None:
+ """Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805)."""
+ pool = _SpyPool()
+ await _run_price_history(pool, status_code=404)
+ assert pool.mark_banned_calls == []
+ assert pool.mark_health_calls == [(9, True)]
+
+
+@pytest.mark.asyncio
+async def test_price_history_aborts_batch_when_pool_empty() -> None:
+ """Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд."""
+ from app.core.config import settings
+
+ pool = _SpyPool(lease=None)
+ # Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1):
+ # только в проде пустой пул = отказ, в dev это легитимный env/direct-путь.
+ with patch.object(settings, "environment", "production"):
+ result = await _run_price_history(pool, status_code=200, n=3)
+ assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен"
+ assert result.errors == 1
+ assert result.checked == 3 # выбрали 3, но оборвались на первом
+
+
+def test_no_proxy_available_is_not_a_platform_ban() -> None:
+ """Наш отказ инфраструктуры не должен читаться как бан площадки (#2616)."""
+ from scraper_kit.proxy_errors import ProxyBanError
+
+ assert not issubclass(NoProxyAvailableError, ProxyBanError)
+
+
+# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ──
+
+
+def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock:
+ """Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин."""
+
+ def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
+ row = diag if "count(*)" in str(stmt).lower() else candidate
+ res = MagicMock()
+ res.mappings.return_value.fetchone.return_value = row
+ return res
+
+ db = MagicMock()
+ db.execute.side_effect = _execute
+ return db
+
+
+def _get_health(db: MagicMock) -> dict[str, Any]:
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+
+ from app.api.v1 import admin as admin_module
+ from app.core.db import get_db
+
+ def _fake_db() -> Any:
+ yield db
+
+ app = FastAPI()
+ app.include_router(admin_module.router, prefix="/api/v1/admin")
+ app.dependency_overrides[get_db] = _fake_db
+
+ async def _no_browser() -> Any:
+ return admin_module.BrowserHealth(reachable=False, browsers={})
+
+ async def _no_ip(proxy_url: str | None) -> str | None:
+ return None
+
+ with (
+ patch.object(admin_module, "_probe_browser_health", _no_browser),
+ patch.object(admin_module, "_probe_current_ip", _no_ip),
+ # Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники.
+ patch.object(
+ admin_module.settings.__class__,
+ "scraper_proxy_url",
+ property(lambda _self: "http://u:p@static-env-node:10423"),
+ ),
+ ):
+ r = TestClient(app).get("/api/v1/admin/scraper/health")
+ assert r.status_code == 200
+ return {p["source"]: p for p in r.json()["providers"]}
+
+
+def test_health_shows_pool_node_not_static_env() -> None:
+ """Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL.
+
+ Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то
+ расхождение «панель показывает один узел, трафик идёт через другой», из-за
+ которого инцидент 2026-08-10 месяц оставался невидимым.
+ """
+ db = _health_db(
+ candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"},
+ diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0},
+ )
+ by_source = _get_health(db)
+ assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"}
+ assert by_source["cian"]["proxy_port"] == 10313
+
+
+def test_health_shows_nothing_when_pool_exhausted_for_source() -> None:
+ """Все узлы забанены для источника → «—», а не статичный env-узел.
+
+ Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть».
+ """
+ db = _health_db(
+ candidate=None,
+ diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0},
+ )
+ by_source = _get_health(db)
+ assert by_source["cian"]["proxy_host"] is None
+ assert by_source["cian"]["proxy_port"] is None
+
+
+# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ─────
+
+
+@dataclass
+class _FakeConfig:
+ use_proxy_pool_curl: bool = True
+ cian_proxy_url: str | None = "http://static-env-node:10423"
+ environment: str = "production"
+
+
+async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any:
+ with patch.object(
+ cian_newbuilding,
+ "build_curl_cffi_session",
+ return_value=_session_returning(status_code, html),
+ ):
+ return await cian_newbuilding.resolve_cian_zhk_url_via_search(
+ 48853, config=_FakeConfig(), proxy_provider=spy
+ )
+
+
+@pytest.mark.asyncio
+async def test_zhk_resolve_uses_pool_node() -> None:
+ """Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url."""
+ spy = _SpyProvider()
+ html = '
'
+ assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
+ assert spy.acquire_calls == ["cian"]
+ assert spy.mark_health_calls == [(9, True)]
+ assert spy.release_calls == [9]
+
+
+@pytest.mark.asyncio
+async def test_zhk_resolve_403_reaches_the_pool() -> None:
+ """Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче."""
+ spy = _SpyProvider()
+ with pytest.raises(CianBlockedError):
+ await _resolve(403, spy)
+ assert spy.mark_banned_calls == [(9, "cian")]
+ assert spy.mark_health_calls == [(9, False)]
+ assert spy.release_calls == [9]
+
+
+@pytest.mark.asyncio
+async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None:
+ """404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем."""
+ spy = _SpyProvider()
+ assert await _resolve(404, spy) is None
+ assert spy.mark_banned_calls == []
+ assert spy.mark_health_calls == [(9, True)]
+
+
+@pytest.mark.asyncio
+async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None:
+ """200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел."""
+ spy = _SpyProvider()
+ assert await _resolve(200, spy, "ничего") is None
+ assert spy.mark_banned_calls == []
+ assert spy.mark_health_calls == [(9, True)]
diff --git a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py
new file mode 100644
index 00000000..884a7c8a
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py
@@ -0,0 +1,290 @@
+"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10).
+
+cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None /
+VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL)
+и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP
+(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в
+момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси.
+
+Покрытие 3 эндпоинтов (db/verify_session/BrowserFetcher мокаются, NO live network/DB),
+зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient):
+ - POST /api/v1/admin/scrape/cian/upload-cookies
+ - POST /api/v1/admin/scrape/cian/auto-login
+ - GET /api/v1/admin/scrape/cian/test-auth
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.services.cian_session import (
+ VERIFY_BAN_SENTINEL,
+ VERIFY_MARKUP_CHANGED_SENTINEL,
+ VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
+)
+
+
+@pytest.fixture
+def client() -> TestClient:
+ from app.api.v1 import admin as admin_module
+ from app.core.db import get_db
+
+ app = FastAPI()
+ app.include_router(admin_module.router, prefix="/api/v1/admin")
+
+ def fake_db():
+ yield MagicMock()
+
+ app.dependency_overrides[get_db] = fake_db
+ return TestClient(app)
+
+
+_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies"
+_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login"
+_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth"
+
+_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"}
+_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}}
+_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}}
+
+
+# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ────────────
+
+
+def test_upload_cookies_ban_returns_503(client: TestClient) -> None:
+ """403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки."""
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
+ ),
+ ):
+ resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
+ assert resp.status_code == 503
+ detail = resp.json()["detail"]
+ assert "заблокировал" in detail
+ assert "прокси" in detail or "egress" in detail
+ assert "бесполезно" in detail
+ # Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой
+ # CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL.
+ assert "SCRAPER_PROXY_URL" in detail
+
+
+def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None:
+ """5xx/сеть → 503, текст говорит повторить позже, куки не трогать."""
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
+ ),
+ ):
+ resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
+ assert resp.status_code == 503
+ detail = resp.json()["detail"]
+ assert "недоступен" in detail
+ assert "позже" in detail
+
+
+def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None:
+ """200 но auth-state не распарсился → 500, текст указывает на фикс парсера."""
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
+ ),
+ ):
+ resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
+ assert resp.status_code == 500
+ detail = resp.json()["detail"]
+ assert "вёрстк" in detail
+ assert "перезалив" in detail
+
+
+def test_upload_cookies_expired_returns_401(client: TestClient) -> None:
+ """None → 401, текст просит перезалить куки (единственный случай re-upload)."""
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=None),
+ ),
+ ):
+ resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
+ assert resp.status_code == 401
+ assert "перезалейте" in resp.json()["detail"]
+
+
+def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None:
+ """Успешный state, но без userId → 400 с уточнением что куки тут ни при чём."""
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID),
+ ),
+ ):
+ resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
+ assert resp.status_code == 400
+ detail = resp.json()["detail"]
+ assert "userId" in detail
+ assert "куки тут ни при" in detail
+
+
+def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=_AUTHENTICATED_STATE),
+ ),
+ patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
+ ):
+ resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES)
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["ok"] is True
+ assert body["userId"] == 102963817
+ mock_save.assert_called_once()
+ _, kwargs = mock_save.call_args
+ assert kwargs["account_user_id"] == 102963817
+
+
+# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ──
+
+
+def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock:
+ fetcher = AsyncMock()
+ fetcher.__aenter__ = AsyncMock(return_value=fetcher)
+ fetcher.__aexit__ = AsyncMock(return_value=None)
+ fetcher.login = AsyncMock(return_value=raw_cookies)
+ return fetcher
+
+
+def test_auto_login_ban_returns_503(client: TestClient) -> None:
+ """Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли"."""
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
+ patch("app.api.v1.admin.settings.cian_login_password", "secret"),
+ patch(
+ "app.api.v1.admin.BrowserFetcher",
+ return_value=_mock_browser_fetcher(_RAW_COOKIES),
+ ),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
+ ),
+ ):
+ resp = client.post(_AUTOLOGIN_URL)
+ assert resp.status_code == 503
+ assert "заблокировал" in resp.json()["detail"]
+
+
+def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"),
+ patch("app.api.v1.admin.settings.cian_login_password", "secret"),
+ patch(
+ "app.api.v1.admin.BrowserFetcher",
+ return_value=_mock_browser_fetcher(_RAW_COOKIES),
+ ),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=_AUTHENTICATED_STATE),
+ ),
+ patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save,
+ ):
+ resp = client.post(_AUTOLOGIN_URL)
+ assert resp.status_code == 200
+ assert resp.json()["userId"] == 102963817
+ mock_save.assert_called_once()
+
+
+# ── GET /scrape/cian/test-auth — reason различает все пять исходов ────────────
+
+
+def test_test_auth_ban_reason(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_BAN_SENTINEL),
+ ),
+ ):
+ resp = client.get(_TEST_AUTH_URL)
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["authenticated"] is False
+ assert body["reason"] == "banned_403"
+
+
+def test_test_auth_source_unavailable_reason(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL),
+ ),
+ ):
+ resp = client.get(_TEST_AUTH_URL)
+ assert resp.status_code == 200
+ assert resp.json()["reason"] == "source_unavailable"
+
+
+def test_test_auth_markup_changed_reason(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL),
+ ),
+ ):
+ resp = client.get(_TEST_AUTH_URL)
+ assert resp.status_code == 200
+ assert resp.json()["reason"] == "markup_changed"
+
+
+def test_test_auth_expired_reason(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=None),
+ ),
+ ):
+ resp = client.get(_TEST_AUTH_URL)
+ assert resp.status_code == 200
+ assert resp.json()["reason"] == "session_expired_or_invalid"
+
+
+def test_test_auth_success(client: TestClient) -> None:
+ with (
+ patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
+ patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES),
+ patch(
+ "app.api.v1.admin.cian_session_svc.verify_session",
+ new=AsyncMock(return_value=_AUTHENTICATED_STATE),
+ ),
+ ):
+ resp = client.get(_TEST_AUTH_URL)
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["authenticated"] is True
+ assert body["userId"] == 102963817
+ assert body["reason"] is None
diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py
index 5ac78324..8a386aa4 100644
--- a/tradein-mvp/backend/tests/test_backfill_wave2.py
+++ b/tradein-mvp/backend/tests/test_backfill_wave2.py
@@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path():
patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
):
mock_mappings = MagicMock()
- mock_mappings.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
- mock_mappings.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
- mock_mappings.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py
index 1c44a0e6..1210e5ec 100644
--- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py
+++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py
@@ -57,8 +57,9 @@ def _mock_db_bti(
address: str | None = "Екатеринбург, улица Малышева, 51",
lat: float = 56.83,
lon: float = 60.6,
+ city: str | None = "Екатеринбург",
) -> MagicMock:
- """Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict."""
+ """Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict."""
db = MagicMock()
@contextmanager
@@ -72,9 +73,10 @@ def _mock_db_bti(
mock_result = MagicMock()
mock_result.fetchone.return_value = None
mock_result.scalar_one_or_none.return_value = None
- if "SELECT address, lat, lon FROM listings" in sql_str:
+ if "SELECT address, city, lat, lon FROM listings" in sql_str:
mock_result.mappings.return_value.first.return_value = {
"address": address,
+ "city": city,
"lat": lat,
"lon": lon,
}
@@ -110,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce():
assert kwargs["ext_source"] == "cian_bti"
assert kwargs["ext_id"] == "123"
assert kwargs["address"] == "Екатеринбург, улица Малышева, 51"
+ # #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение;
+ # без него бескоординатная BTI-карточка матчится в одноимённый дом другого города.
+ assert kwargs["city"] == "Екатеринбург"
call = _bti_update_call(db)
assert call is not None, "UPDATE houses с BTI-полями не найден"
diff --git a/tradein-mvp/backend/tests/test_cian_session.py b/tradein-mvp/backend/tests/test_cian_session.py
index ee4e50e6..4468e8ed 100644
--- a/tradein-mvp/backend/tests/test_cian_session.py
+++ b/tradein-mvp/backend/tests/test_cian_session.py
@@ -32,6 +32,21 @@ def mock_db() -> MagicMock:
return db
+@pytest.fixture(autouse=True)
+def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None:
+ """#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а
+ зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри
+ app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py).
+ Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД,
+ из-за чего verify_session уходил в generic except ДО session.get и все
+ verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля,
+ чтобы не трогать каждый тест по отдельности."""
+ monkeypatch.setattr(
+ "app.services.cian_session.resolve_proxy_url_sync",
+ lambda source: "http://test-proxy.local:8080",
+ )
+
+
# ---------------------------------------------------------------------------
# CIAN_REQUIRED_COOKIES
# ---------------------------------------------------------------------------
@@ -424,6 +439,40 @@ async def test_verify_session_network_error_returns_source_unavailable_sentinel(
assert result is not None
+@pytest.mark.asyncio
+async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ """#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы
+ забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress),
+ возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning,
+ отдельным от обычного network-error пути) — явная деградация, а не проглатывание."""
+ from app.services.proxy_egress import ProxyPoolExhaustedError
+
+ def _raise(source: str) -> str | None:
+ raise ProxyPoolExhaustedError(
+ source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0
+ )
+
+ monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise)
+
+ mock_session = AsyncMock()
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+ mock_session.get = AsyncMock(return_value=_make_cffi_resp(200))
+
+ with (
+ patch("app.services.cian_session.AsyncSession", return_value=mock_session),
+ caplog.at_level("WARNING"),
+ ):
+ result = await verify_session({"DMIR_AUTH": "x"})
+
+ assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
+ mock_session.get.assert_not_called()
+ errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
+ assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors)
+
+
@pytest.mark.asyncio
async def test_verify_session_uses_chrome120_impersonate() -> None:
"""curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py
index a9ed8d31..099d39d0 100644
--- a/tradein-mvp/backend/tests/test_estimate_idor.py
+++ b/tradein-mvp/backend/tests/test_estimate_idor.py
@@ -57,6 +57,12 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every
row that existed before that migration; explicit param lets retention-gate
tests (see test_estimate_retention_gate.py) construct a paid row.
+
+ relaxations/reliability (migration 255) default to the schema defaults
+ ('[]' / 'ok') -- matches every pre-migration row (no backfill). Revival
+ scenarios (a "dead" median_price<=0/NULL row) are covered separately in
+ test_estimate_revival.py with their own dedicated row builders, since this
+ fixture's downstream tests here all assume a "live" estimate.
"""
from datetime import UTC, datetime, timedelta
@@ -99,6 +105,8 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
ratio_basis="per_rooms",
created_by=created_by,
created_at=datetime.now(tz=UTC),
+ relaxations=[],
+ reliability="ok",
)
@@ -155,6 +163,10 @@ def _stub_precision_and_pdf():
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
# helper. Empty analogs + no valuation flags → [] (real behaviour).
_canonical_sources=lambda *a, **k: [],
+ # #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих
+ # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
+ # поведение (см. tests/test_estimator_search_radius_2632.py).
+ rehydrate_search_radius_m=lambda *a, **k: None,
)
real_estimator = sys.modules.get("app.services.estimator")
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py
new file mode 100644
index 00000000..d41f2e4d
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_estimate_revival.py
@@ -0,0 +1,602 @@
+"""Tests for GET /estimate/{id} revival of "dead" (median_price<=0/NULL) rows.
+
+Incident 2026-08-10: a customer opened a saved estimate link and saw
+«НЕДОСТАТОЧНО ДАННЫХ» — the row was created BEFORE the estimator fix
+(#oblast-E/#oblast-F, PR #2823/#2825) and is permanently stuck at
+median_price=0, even though the same address/params now compute a real
+price. app.api.v1.trade_in::_try_revive_dead_estimate recomputes such a row
+in place (same id/link) via the same estimate_quality() path as POST
+/estimate. Also covers migration 255 (relaxations/reliability persistence).
+
+Реальная БД не нужна: DB + get_role + estimate_quality мокируются, mirroring
+test_estimate_idor.py's approach (self-contained, no cross-file fixture
+imports — this repo has no precedent for importing fixtures across
+tests/test_*.py modules, only from tests/support/).
+
+DB mock dispatches by SQL substring (not call-position): get_estimate's
+existing rehydrate path calls the real (non-stubbed) _resolve_target_house_id
+helper, which itself fires 1-2 incidental `SELECT id FROM houses` queries
+whenever the revival attempt does NOT short-circuit with an early return —
+hand-counting positional side_effect entries around that would be brittle.
+"""
+
+from __future__ import annotations
+
+import inspect
+import os
+import sys
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+# psycopg v3 driver required; stub DATABASE_URL before any app import
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import.
+_wp_mock = MagicMock()
+sys.modules.setdefault("weasyprint", _wp_mock)
+sys.modules.setdefault("weasyprint.CSS", _wp_mock)
+sys.modules.setdefault("weasyprint.HTML", _wp_mock)
+
+import pytest # noqa: E402
+from fastapi import FastAPI # noqa: E402
+from fastapi.testclient import TestClient # noqa: E402
+
+_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222"
+_TEMP_ID = "33333333-3333-3333-3333-333333333333"
+
+
+@pytest.fixture(autouse=True)
+def _restore_get_role():
+ """Restore app.core.auth.get_role after each test (mirror test_estimate_idor)."""
+ from app.core import auth as auth_mod
+
+ original = auth_mod.get_role
+ yield
+ auth_mod.get_role = original
+
+
+@pytest.fixture()
+def trade_in_app() -> FastAPI:
+ """Minimal FastAPI app mounting only the trade-in router."""
+ from app.api.v1 import trade_in as trade_in_module
+
+ application = FastAPI()
+ application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
+ return application
+
+
+def _make_dead_row(
+ *,
+ median_price: int | None = 0,
+ house_type: str | None = None,
+ repair_state: str | None = None,
+ relaxations: list[str] | None = None,
+ reliability: str = "ok",
+) -> SimpleNamespace:
+ """A trade_in_estimates row in the "dead" state (incident 2026-08-10).
+
+ house_type/repair_state default to None (valid TradeInEstimateInput input —
+ most revival tests need a row that round-trips through pydantic cleanly);
+ a dedicated test passes legacy Russian literal values to exercise the
+ "invalid persisted value" graceful-degrade path.
+ """
+ return SimpleNamespace(
+ id=_ESTIMATE_ID,
+ median_price=median_price,
+ range_low=0,
+ range_high=0,
+ median_price_per_m2=0,
+ confidence="low",
+ confidence_explanation="Рядом найдено недостаточно объявлений (4 шт.)",
+ n_analogs=0,
+ analogs=[],
+ actual_deals=[],
+ sources_used=[],
+ data_freshness_minutes=None,
+ expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
+ retain_until=None,
+ address="г Екатеринбург, ул Академика Парина, д 46, к 5",
+ lat=56.8519,
+ lon=60.6122,
+ area_m2=23.1,
+ rooms=1,
+ floor=5,
+ total_floors=16,
+ year_built=2018,
+ house_type=house_type,
+ repair_state=repair_state,
+ has_balcony=None,
+ canonical_address=None,
+ house_cadnum=None,
+ house_fias_id=None,
+ dadata_qc_geo=None,
+ dadata_metro=[],
+ expected_sold_price=None,
+ expected_sold_range_low=None,
+ expected_sold_range_high=None,
+ expected_sold_per_m2=None,
+ asking_to_sold_ratio=None,
+ ratio_basis=None,
+ created_by="kopylov",
+ created_at=datetime(2026, 5, 29, tzinfo=UTC),
+ relaxations=relaxations or [],
+ reliability=reliability,
+ )
+
+
+def _make_live_row(
+ *, relaxations: list[str] | None = None, reliability: str = "ok"
+) -> SimpleNamespace:
+ """A "live" row (median_price>0) — revival must never touch it."""
+ return SimpleNamespace(
+ id=_ESTIMATE_ID,
+ median_price=4_031_157,
+ range_low=3_700_000,
+ range_high=4_300_000,
+ median_price_per_m2=174_000,
+ confidence="medium",
+ confidence_explanation="Найдено 39 аналогов",
+ n_analogs=39,
+ analogs=[],
+ actual_deals=[],
+ sources_used=["avito", "rosreestr"],
+ data_freshness_minutes=15,
+ expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
+ retain_until=None,
+ address="г Екатеринбург, ул Академика Парина, д 46, к 5",
+ lat=56.8519,
+ lon=60.6122,
+ area_m2=23.1,
+ rooms=1,
+ floor=5,
+ total_floors=16,
+ year_built=2018,
+ house_type=None,
+ repair_state=None,
+ has_balcony=None,
+ canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
+ house_cadnum=None,
+ house_fias_id=None,
+ dadata_qc_geo=0,
+ dadata_metro=[],
+ expected_sold_price=None,
+ expected_sold_range_low=None,
+ expected_sold_range_high=None,
+ expected_sold_per_m2=None,
+ asking_to_sold_ratio=None,
+ ratio_basis=None,
+ created_by="kopylov",
+ created_at=datetime.now(tz=UTC),
+ relaxations=relaxations or [],
+ reliability=reliability,
+ )
+
+
+def _fake_revived_result(**overrides):
+ """A canned AggregatedEstimate mimicking a successful estimate_quality() call."""
+ from app.schemas.trade_in import AggregatedEstimate
+
+ defaults = dict(
+ estimate_id=_TEMP_ID,
+ median_price_rub=4_031_157,
+ range_low_rub=3_700_000,
+ range_high_rub=4_300_000,
+ median_price_per_m2=174_000,
+ confidence="medium",
+ confidence_explanation="Найдено 39 аналогов",
+ n_analogs=39,
+ period_months=12,
+ analogs=[],
+ actual_deals=[],
+ expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
+ target_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
+ target_lat=56.8519,
+ target_lon=60.6122,
+ sources_used=["avito", "rosreestr"],
+ data_freshness_minutes=15,
+ canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
+ relaxations=["снят фильтр по году постройки", "учтены студии"],
+ reliability="low",
+ created_at=datetime.now(tz=UTC),
+ )
+ defaults.update(overrides)
+ return AggregatedEstimate(**defaults)
+
+
+def _dispatch_db(row: object, claim_result: object = None) -> MagicMock:
+ """DB session mock dispatching fetchone() results by SQL substring.
+
+ - initial GET SELECT ("SELECT id, median_price ...") -> row
+ - revival claim UPDATE ("SET revival_attempted_at") -> claim_result
+ - everything else (houses lookup, persist UPDATE, DELETE,
+ avito_imv UPDATE — none of which .fetchone() in real code
+ except the two above, but MagicMock tolerates the unused
+ call either way) -> None
+
+ call_args_list still records every call in order regardless of dispatch,
+ so tests can assert on it directly (grep by substring) without needing to
+ hand-count incidental queries fired by _resolve_target_house_id.
+ """
+ db = MagicMock()
+
+ def _execute(clause, params=None, *_a, **_k):
+ sql = getattr(clause, "text", str(clause))
+ result = MagicMock()
+ if "SET revival_attempted_at" in sql:
+ result.fetchone.return_value = claim_result
+ elif "SELECT id, median_price" in sql:
+ result.fetchone.return_value = row
+ else:
+ result.fetchone.return_value = None
+ return result
+
+ db.execute.side_effect = _execute
+ return db
+
+
+def _calls_containing(db: MagicMock, needle: str) -> list:
+ return [c for c in db.execute.call_args_list if needle in getattr(c.args[0], "text", "")]
+
+
+def _client_with(app: FastAPI, db_mock: MagicMock, role: str = "pilot") -> TestClient:
+ from app.core.db import get_db
+
+ def _override_db():
+ yield db_mock
+
+ app.dependency_overrides[get_db] = _override_db
+ auth_mod = sys.modules["app.core.auth"]
+ auth_mod.get_role = lambda _u: role # type: ignore[assignment]
+ return TestClient(app)
+
+
+@pytest.fixture()
+def _estimator_stub():
+ """Replaces app.services.estimator with a SimpleNamespace stub.
+
+ Mirrors test_estimate_idor.py::_stub_precision_and_pdf, plus an
+ `estimate_quality` async attribute (revival's own lazy import target).
+ Individual tests overwrite `estimate_quality` per-scenario.
+ """
+ real_estimator = sys.modules.get("app.services.estimator")
+
+ async def _default_estimate_quality(*_a, **_k): # pragma: no cover — overridden per test
+ raise AssertionError("estimate_quality stub not configured for this test")
+
+ stub = SimpleNamespace(
+ _qc_geo_to_precision=lambda _qc: None,
+ _fetch_price_trend=lambda *a, **k: None,
+ _fetch_dkp_corridor=lambda *a, **k: None,
+ _fetch_house_imv_anchor=lambda *a, **k: None,
+ _resolve_target_city=lambda *a, **k: None,
+ _cv_from_ppm2=lambda *a, **k: None,
+ _source_counts=lambda *a, **k: {},
+ _canonical_sources=lambda *a, **k: [],
+ # #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк
+ # этой фикстуры analogs пусты → None и есть настоящее поведение
+ # (см. tests/test_estimator_search_radius_2632.py).
+ rehydrate_search_radius_m=lambda *a, **k: None,
+ estimate_quality=_default_estimate_quality,
+ )
+ sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
+ yield stub
+ if real_estimator is not None:
+ sys.modules["app.services.estimator"] = real_estimator
+ else:
+ sys.modules.pop("app.services.estimator", None)
+
+
+# ── Revival success ───────────────────────────────────────────────────────
+
+
+def test_dead_row_revives_and_updates_db(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """Dead row (median_price=0) → recomputed, written to the SAME id, returned fresh.
+
+ Success path returns early (before the pre-existing rehydrate block), so
+ the call count is exactly the 5 documented in _try_revive_dead_estimate's
+ docstring: claim, avito_imv relink, persist UPDATE, DELETE temp — plus the
+ initial SELECT.
+ """
+
+ async def _fake_estimate_quality(payload, db, **kwargs):
+ assert payload.address.startswith("г Екатеринбург")
+ assert payload.rooms == 1
+ assert payload.radius_m is None # never persisted — default cascade
+ return _fake_revived_result()
+
+ _estimator_stub.estimate_quality = _fake_estimate_quality
+
+ row = _make_dead_row()
+ db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ # id/link contract: response carries the ORIGINAL id, not the temp uuid
+ # estimate_quality() minted internally.
+ assert body["estimate_id"] == _ESTIMATE_ID
+ assert body["median_price_rub"] == 4_031_157
+ assert body["n_analogs"] == 39
+ assert body["insufficient_data"] is False
+ assert body["reliability"] == "low"
+ assert "снят фильтр по году постройки" in body["relaxations"]
+
+ assert len(db.execute.call_args_list) == 5
+ persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET")
+ assert len(persist_calls) == 1
+ persist_params = persist_calls[0].args[1]
+ assert persist_params["id"] == _ESTIMATE_ID
+ assert persist_params["median_price"] == 4_031_157
+ assert persist_params["reliability"] == "low"
+ delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates")
+ assert len(delete_calls) == 1
+ assert delete_calls[0].args[1]["id"] == _TEMP_ID
+
+
+def test_dead_row_revival_preserves_created_at(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """#incident-2026-08-11: created_at is the client's ORIGINAL request date
+ (printed on /history, see app/schemas/trade_in.py:317-318) — revival must
+ not clobber it with the throwaway temp row's NOW(). Regression for the
+ prod incident (estimate ff421062-...: created_at jumped from the original
+ 2026-08-10 12:54:47 to the revival moment 2026-08-11 04:30:03).
+
+ Also asserts: (a) the persist UPDATE never sets created_at at all — the
+ fix removes the column from SET, it doesn't just overwrite it with the
+ right value; (b) migration 256's revival_completed_at IS stamped, as the
+ separate "when did revival last succeed" audit trail; (c) the JSON
+ response mirrors the original created_at, not the temp result's.
+ """
+ _original_created_at = datetime(2026, 5, 29, tzinfo=UTC)
+
+ async def _fake_estimate_quality(payload, db, **kwargs):
+ # The temp row estimate_quality() mints internally always carries
+ # NOW() as its created_at — deliberately far from the original, so a
+ # regression (copying result.created_at through) is unmissable.
+ return _fake_revived_result(created_at=datetime(2026, 8, 11, 4, 30, 3, tzinfo=UTC))
+
+ _estimator_stub.estimate_quality = _fake_estimate_quality
+
+ row = _make_dead_row()
+ row.created_at = _original_created_at
+ db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["created_at"] == "2026-05-29T00:00:00Z"
+
+ persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET")
+ assert len(persist_calls) == 1
+ persist_sql = persist_calls[0].args[0].text
+ persist_params = persist_calls[0].args[1]
+ assert "created_at" not in persist_sql
+ assert "created_at" not in persist_params
+ assert "revival_completed_at = NOW()" in persist_sql
+ # Input-snapshot / TTL columns (what the client originally asked for and
+ # for how long the row is retained) are likewise not recompute outputs —
+ # untouched by the revival persist UPDATE.
+ # NB: "address" is checked via persist_params only (not persist_sql) —
+ # canonical_address (a legitimate recompute output) ends in "address =",
+ # which would false-positive a substring check against the raw SQL text.
+ for protected in ("expires_at", "retain_until", "created_by"):
+ assert f"{protected} =" not in persist_sql
+ assert protected not in persist_params
+ assert "address" not in persist_params
+
+
+# ── Live row is never touched ────────────────────────────────────────────
+
+
+def test_live_row_never_triggers_revival(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """median_price>0 → revival branch skipped entirely: estimate_quality is
+ never called and no claim UPDATE fires — the saved price is untouched."""
+
+ async def _must_not_be_called(*_a, **_k):
+ raise AssertionError("estimate_quality must not be called for a live row")
+
+ _estimator_stub.estimate_quality = _must_not_be_called
+
+ row = _make_live_row()
+ db = _dispatch_db(row)
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["median_price_rub"] == 4_031_157
+ assert _calls_containing(db, "SET revival_attempted_at") == []
+
+
+# ── Throttle / anti-storm ────────────────────────────────────────────────
+
+
+def test_dead_row_throttled_does_not_recompute(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """Claim UPDATE returns no row (recent attempt / lost race) → no recompute,
+ honest still-dead response, no 500, exactly one claim attempt (no retry
+ loop within the same request)."""
+
+ async def _must_not_be_called(*_a, **_k):
+ raise AssertionError("estimate_quality must not be called when throttled")
+
+ _estimator_stub.estimate_quality = _must_not_be_called
+
+ row = _make_dead_row()
+ db = _dispatch_db(row, claim_result=None) # throttled: WHERE matched nothing
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["insufficient_data"] is True
+ assert body["median_price_rub"] == 0
+ assert len(_calls_containing(db, "SET revival_attempted_at")) == 1
+
+
+# ── Recompute error degrades gracefully (no 500) ─────────────────────────
+
+
+def test_revival_recompute_exception_falls_back_without_500(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ async def _raises(*_a, **_k):
+ raise RuntimeError("geocode timeout")
+
+ _estimator_stub.estimate_quality = _raises
+
+ row = _make_dead_row()
+ db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["insufficient_data"] is True
+ # exception happened AFTER the claim — throttle was still recorded once.
+ assert len(_calls_containing(db, "SET revival_attempted_at")) == 1
+ # ...but nothing was written back to the row (no persist UPDATE fired).
+ assert _calls_containing(db, "UPDATE trade_in_estimates SET") == []
+
+
+def test_revival_still_zero_falls_back_without_500(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """Recompute runs but still finds nothing (median_price_rub=0) — honest
+ insufficient_data, temp throwaway row cleaned up, no crash."""
+
+ async def _still_empty(*_a, **_k):
+ return _fake_revived_result(
+ median_price_rub=0,
+ range_low_rub=0,
+ range_high_rub=0,
+ median_price_per_m2=0,
+ n_analogs=0,
+ confidence="low",
+ )
+
+ _estimator_stub.estimate_quality = _still_empty
+
+ row = _make_dead_row()
+ db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["insufficient_data"] is True
+ delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates")
+ assert len(delete_calls) == 1
+ assert delete_calls[0].args[1]["id"] == _TEMP_ID
+ # the still-dead ORIGINAL row was never overwritten with (fresh) zeros.
+ assert _calls_containing(db, "UPDATE trade_in_estimates SET") == []
+
+
+def test_revival_invalid_persisted_house_type_falls_back_gracefully(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """Legacy row with a house_type outside the current Literal set — payload
+ reconstruction itself raises (pydantic ValidationError), caught the same
+ way as any other recompute failure. No 500."""
+
+ async def _must_not_be_called(*_a, **_k):
+ raise AssertionError("estimate_quality must not be reached — payload build fails first")
+
+ _estimator_stub.estimate_quality = _must_not_be_called
+
+ row = _make_dead_row(house_type="монолит", repair_state="хороший")
+ db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["insufficient_data"] is True
+
+
+# ── relaxations/reliability round-trip (migration 255) ───────────────────
+
+
+def test_relaxations_reliability_roundtrip_on_get(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """A live row with persisted relaxations/reliability surfaces them
+ byte-for-byte on GET — the red "точность снижена" banner survives
+ reopening a saved link (previously always reset to ok/[])."""
+ row = _make_live_row(
+ relaxations=["радиус расширен до 2000 м", "площадь ±25%"], reliability="low"
+ )
+ db = _dispatch_db(row)
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["reliability"] == "low"
+ assert body["relaxations"] == ["радиус расширен до 2000 м", "площадь ±25%"]
+
+
+def test_get_estimate_defaults_relaxations_reliability_when_row_lacks_columns(
+ trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
+) -> None:
+ """Defensive getattr fallback: a row/mock without relaxations/reliability
+ attrs (e.g. a stale test double) degrades to schema defaults, not a crash."""
+ row = _make_live_row()
+ del row.relaxations
+ del row.reliability
+ db = _dispatch_db(row)
+ client = _client_with(trade_in_app, db)
+ resp = client.get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "kopylov"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["reliability"] == "ok"
+ assert body["relaxations"] == []
+
+
+# ── estimator.py persists relaxations/reliability on (re)compute ─────────
+
+
+def test_estimate_quality_insert_persists_relaxations_reliability() -> None:
+ """Source guard: the main POST-path INSERT must write relaxations/
+ reliability, not just return them in the response — regression guard
+ against the exact gap this migration closes (PR #2823 open follow-up)."""
+ from app.services import estimator
+
+ src = inspect.getsource(estimator.estimate_quality)
+ assert "relaxations_json" in src
+ assert '"reliability": reliability' in src
+
+
+def test_empty_estimate_persists_relaxations_reliability() -> None:
+ """_empty_estimate's INSERT must also set reliability='very_low' (mirrors
+ the Python object it returns) rather than silently defaulting to 'ok'."""
+ from app.services import estimator
+
+ src = inspect.getsource(estimator._empty_estimate)
+ assert "relaxations, reliability" in src
+ assert "'very_low'" in src
diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py
new file mode 100644
index 00000000..16815488
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py
@@ -0,0 +1,263 @@
+"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11).
+
+`confidence` (`_compute_confidence` — unique-address/IQR metric) and
+`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation-
+cascade metric, see #2823) were computed fully independently. The cascade's
+room-adjacency/freshness/novostroyki steps never set `fallback_used`, so
+`_compute_confidence` stayed blind to them and could keep scoring a wide,
+tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES
+see the relaxations — honestly read "low".
+
+Live prod repro (customer address, rooms=1, 23.1 m², radius=2000):
+ n_analogs: 39
+ confidence: high
+ reliability: low
+ relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%']
+
+A client could see a "высокая уверенность" badge next to a "точность снижена"
+banner on the SAME estimate — exactly the class of contradiction cleaned up
+from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking
+through the two headline confidence signals themselves.
+
+Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in
+`estimate_quality` right after both `confidence` and `reliability` are final
+— NOT spread across the #oblast-F cascade steps. Rule:
+ reliability == 'very_low' → confidence forced to 'low'
+ reliability == 'low' → confidence capped at 'medium'
+ reliability == 'ok' → confidence untouched (common case, unaffected)
+
+Two layers:
+ 1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in
+ isolation, no DB/estimate_quality overhead.
+ 2. `estimate_quality` integration tests — the prod repro shape (confidence
+ downgraded + confidence_explanation restructured to LEAD with the
+ accuracy-reduced summary) and the byte-identical-when-unrelaxed control.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import anyio
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from app.services.estimator import _cap_confidence_by_reliability
+from app.services.geocoder import GeocodeResult
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Layer 1 — `_cap_confidence_by_reliability` direct unit tests
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def test_very_low_reliability_forces_confidence_low() -> None:
+ """reliability == 'very_low' → confidence forced to 'low', regardless of
+ what _compute_confidence originally scored."""
+ for original in ("high", "medium", "low"):
+ assert (
+ _cap_confidence_by_reliability(original, "very_low") == "low"
+ ), f"original={original!r} must be forced to 'low' under very_low reliability"
+
+
+def test_low_reliability_caps_confidence_at_medium() -> None:
+ """reliability == 'low' → confidence capped at 'medium': 'high' is pulled
+ down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap)."""
+ assert _cap_confidence_by_reliability("high", "low") == "medium"
+ assert _cap_confidence_by_reliability("medium", "low") == "medium"
+ assert _cap_confidence_by_reliability("low", "low") == "low"
+
+
+def test_ok_reliability_leaves_confidence_untouched() -> None:
+ """reliability == 'ok' — the common, unrelaxed case — must NOT change
+ confidence at all (byte-identical to pre-#2827 behaviour)."""
+ for original in ("high", "medium", "low"):
+ assert _cap_confidence_by_reliability(original, "ok") == original
+
+
+def test_cap_never_raises_confidence() -> None:
+ """Sanity: the cap only lowers/holds — never raises 'low' to something
+ higher under any reliability value."""
+ for reliability in ("ok", "low", "very_low"):
+ assert _cap_confidence_by_reliability("low", reliability) == "low"
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def _geo() -> GeocodeResult:
+ return GeocodeResult(
+ lat=56.838,
+ lon=60.595,
+ full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5",
+ provider="nominatim",
+ )
+
+
+def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]:
+ return {
+ "source": "avito",
+ "source_url": f"https://avito.ru/offer/{address}",
+ "address": address,
+ "lat": 56.838,
+ "lon": 60.595,
+ "rooms": 1,
+ "area_m2": area_m2,
+ "floor": 5,
+ "total_floors": 9,
+ "price_rub": price_per_m2 * area_m2,
+ "price_per_m2": price_per_m2,
+ "listing_date": datetime(2026, 5, 1),
+ "days_on_market": 10,
+ "photo_urls": [],
+ "scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
+ "distance_m": 150.0,
+ "relevance_score": 0.1,
+ }
+
+
+def _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]:
+ """n listings, distinct addresses, tightly clustered price/m² (~±2%) —
+ engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7
+ AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39,
+ confidence='high' pre-fix)."""
+ return [
+ _make_listing(
+ price_per_m2=base_ppm2 + (i - n / 2) * 200,
+ address=f"ул. Академика Парина, {i + 1}",
+ )
+ for i in range(n)
+ ]
+
+
+def _payload_with_year() -> Any:
+ from app.schemas.trade_in import TradeInEstimateInput
+
+ return TradeInEstimateInput(
+ address="ЕКБ, ул. Академика Парина, 46/5",
+ area_m2=23.1,
+ rooms=1,
+ floor=5,
+ total_floors=9,
+ year_built=2010,
+ city_hint="Екатеринбург",
+ radius_m=2000,
+ )
+
+
+def _run_estimate(
+ *,
+ payload: Any,
+ fetch_analogs_side_effect: Any,
+ dkp_raw: dict[str, Any] | None = None,
+) -> Any:
+ from app.services.estimator import estimate_quality
+
+ db = MagicMock()
+ geo = _geo()
+
+ async def _run() -> Any:
+ with (
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
+ patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
+ patch("app.services.estimator.match_house_readonly", return_value=None),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
+ patch(
+ "app.services.estimator._fetch_analogs",
+ side_effect=fetch_analogs_side_effect,
+ ),
+ patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
+ patch("app.services.estimator._fetch_deals", return_value=[]),
+ patch(
+ "app.services.estimator._get_or_fetch_imv_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator._get_or_fetch_yandex_valuation_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator.estimate_via_cian_valuation",
+ new=AsyncMock(return_value=None),
+ ),
+ patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
+ ):
+ return await estimate_quality(payload, db)
+
+ return anyio.run(_run)
+
+
+def test_e2e_prod_repro_high_confidence_low_reliability_gets_capped() -> None:
+ """Live prod repro shape: cohort (year_built) filter gets dropped by the
+ Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing,
+ tight-price, 39-unique-address pool — a healthy sample that
+ `_compute_confidence` would score 'high' on its own. Because a relaxation
+ WAS applied to get there, `reliability` reads 'low' — the cap must pull
+ `confidence` down to 'medium' (not leave it at the contradictory 'high'),
+ and `confidence_explanation` must LEAD with the accuracy-reduced summary."""
+ pool = _tight_price_pool(39)
+
+ def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
+ # Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback.
+ if kwargs.get("cohort_year_min") is not None:
+ return [], False, "W"
+ # Every subsequent (no-cohort) call → the full healthy pool.
+ return list(pool), False, "W"
+
+ est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub)
+
+ assert est.n_analogs == 39
+ assert est.reliability == "low"
+ assert "снят фильтр по году постройки" in est.relaxations
+ assert est.confidence == "medium", (
+ f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' "
+ "(was 'high' pre-#2827 — the exact prod contradiction)"
+ )
+ explanation = est.confidence_explanation
+ assert explanation is not None
+ assert explanation.startswith(
+ "Оценка построена с расширенными параметрами подбора — точность снижена."
+ ), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}"
+ assert "Найдено 39 аналогов" in explanation, "original detail must be preserved"
+ assert "Применены послабления подбора: снят фильтр по году постройки." in explanation
+
+
+def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None:
+ """#3 (task spec): no relaxations, reliability=='ok' → confidence and
+ confidence_explanation must be UNCHANGED by #2827 — this is the common
+ path most estimates take, and it must not regress."""
+ pool = _tight_price_pool(39)
+
+ def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
+ # No cohort filter this time (payload has no year_built) — Tier 0 is
+ # skipped outright (cohort_range is None), so no relaxation fires.
+ return list(pool), False, "W"
+
+ from app.schemas.trade_in import TradeInEstimateInput
+
+ payload = TradeInEstimateInput(
+ address="ЕКБ, ул. Академика Парина, 46/5",
+ area_m2=23.1,
+ rooms=1,
+ floor=5,
+ total_floors=9,
+ city_hint="Екатеринбург",
+ )
+ est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub)
+
+ assert est.n_analogs == 39
+ assert est.relaxations == []
+ assert est.reliability == "ok"
+ assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence"
+ assert est.confidence_explanation is not None
+ assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами")
+ assert "Применены послабления подбора" not in est.confidence_explanation
+ assert est.confidence_explanation.startswith("Найдено 39 аналогов"), (
+ "unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: "
+ f"{est.confidence_explanation!r}"
+ )
diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
index 3ae13ef7..a4033a0f 100644
--- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
+++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
@@ -1,22 +1,55 @@
-"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02).
+"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority
+RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product
+decision, 2026-08-10).
-Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings →
-headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring
-street in the same town swung ±66% on 1-2 different random listings. Каменск-
-Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match
-either, with no honest refusal surfaced. Первоуральск (0 listings) already fell
-back to the (pre-existing) ДКП deals-headline fallback correctly — this gate
-routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME,
-already-tested path instead of trusting a 1-4-lot median as the headline.
+History:
+ 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1)
+ listings sample to a literal zero, forcing the anchor/#oblast-D-deals-
+ fallback/insufficient_data chain to take over — motivated by a live
+ Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of
+ 54 126 ₽/м²).
+ 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE —
+ a thin sample always kept its own median, even when a much more reliable
+ deals corridor was available. That accidentally REOPENED the exact Серов
+ bug #oblast-E existed to close.
+ 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction):
+ "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3
+ объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in
+ `_price_from_inputs`'s gate:
+ - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected).
+ - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor
+ exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) →
+ listings aggregate suppressed to zero, headline ceded to the
+ #oblast-D deals-headline-fallback chain (original #oblast-E
+ behaviour, restored). `PricingResult.deals_headline_due_to_thin_
+ listings=True` — estimate_quality() adds relaxation label "оценка по
+ сделкам — мало объявлений рядом" and caps reliability at 'low'.
+ Listings display cards are NOT hidden (unlike original #oblast-E) —
+ `listings_clean` stays intact and estimate_quality() still surfaces
+ them as context even though they no longer drive n_analogs/median.
+ - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor →
+ #oblast-F: keep the real thin median (never refuse outright).
+ Real refusal ("недостаточно данных") now happens ONLY at genuine n=0
+ (no listings AND no usable anchor/deals) — the never-block requirement
+ with an honest, priority-ordered source selection.
+
+`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F
+relaxation cascade (room-adjacency / freshness / novostroyki / radius, see
+estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in
+Layer 1) ever runs the 3-way gate above — `listings` here is whatever that
+cascade could find.
Two layers:
1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) —
- boundary behaviour of the gate itself.
+ boundary behaviour of the gate itself: the 3-way rule, low-reliability
+ wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_
+ thin_listings bookkeeping.
2. `estimate_quality` integration tests — proves the money-path invariants
- that matter to a caller: literal 0 never leaks as a "confident" price,
- display `analogs` cards never outnumber what `n_analogs` claims, and the
- explanation text describes what actually happened (not a stock "аналогов
- не найдено" when some WERE found, just too few).
+ that matter to a caller: thin+usable-deals routes to the deals corridor
+ (Серов repro), thin+no-deals keeps its own median, display `analogs`
+ cards are shown either way, and the #oblast-F room-adjacency relaxation
+ (studio↔1-комн) actually grows a thin sample and is reported via
+ `AggregatedEstimate.relaxations` / `reliability`.
"""
from __future__ import annotations
@@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None:
assert HEADLINE_LISTINGS_MIN_N == 5
-def test_four_listings_below_threshold_suppressed_no_fallback() -> None:
- """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state,
- NOT the naive median of 4 listings."""
+def test_four_listings_below_threshold_kept_not_suppressed() -> None:
+ """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision
+ 2026-08-10 — never zero out a thin-but-real sample), just flagged low."""
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0]))
- assert pr.median_ppm2 == 0.0
- assert pr.median_price == 0
- assert pr.n_analogs == 0
- assert pr.range_low == 0
- assert pr.range_high == 0
+ assert pr.median_ppm2 == 215_000.0
+ assert pr.n_analogs == 4
+ assert pr.median_price == round(215_000.0 * 45.0)
+ assert pr.confidence == "low"
def test_five_listings_at_threshold_not_suppressed() -> None:
@@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None:
assert pr.median_price == round(210_000.0 * 45.0)
-def test_one_listing_below_threshold_suppressed() -> None:
- """n=1 — the sharpest form of the Серов bug (a single random lot deciding
- the whole headline) — must be suppressed exactly like n=4."""
+def test_one_listing_below_threshold_kept_not_suppressed() -> None:
+ """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot)
+ median rather than being zeroed; confidence stays 'low'."""
pr = _call(listings=_lots([200_000.0]))
- assert pr.median_ppm2 == 0.0
- assert pr.n_analogs == 0
+ assert pr.median_ppm2 == 200_000.0
+ assert pr.n_analogs == 1
+ assert pr.confidence == "low"
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
- """n=3 listings (thin) + a usable ДКП corridor → headline comes from the
- deal corridor median, NOT the 3-listing median (live Серов repro: 3
- listings gave 42 391 vs the honest ДКП-based ~54 126)."""
+ """#oblast-E priority RESTORED (2026-08-10 product correction): a thin
+ (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this
+ is the exact live Серов repro #oblast-E exists for (3 noisy listings gave
+ 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline
+ comes from the deal corridor median, NOT the 3-listing median."""
dkp_raw = {
"count": 54,
"low_ppm2": 44_000,
@@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
)
assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline"
assert pr.confidence == "low"
+ assert pr.deals_headline_due_to_thin_listings is True
+ assert pr.listings_clean, "listings_clean must stay intact — display cards still show them"
+ # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE
+ # found, just ceded priority to the more reliable deals corridor) and must
+ # NOT also carry the separate "Оценка построена по N аналогам" thin-kept
+ # wording (that phrasing is reserved for the no-usable-corridor branch).
+ assert pr.explanation is not None
+ assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
+ assert "сделкам росреестра" in pr.explanation.lower()
+ assert "оценка построена по 3" not in pr.explanation.lower()
-def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
+def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None:
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
- (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero,
- not a fabricated number from either side."""
+ (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so
+ #oblast-F's never-block rule applies: the real listings median is kept
+ rather than refusing (neither source alone would justify a hard zero)."""
dkp_raw = {
"count": 1,
"low_ppm2": 40_000,
@@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
"period_months": 12,
}
pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw)
- assert pr.median_ppm2 == 0.0
- assert pr.median_price == 0
- assert pr.n_analogs == 0
+ assert pr.median_ppm2 == 42_391.0
+ assert pr.n_analogs == 3
+ assert pr.deals_headline_due_to_thin_listings is False
-def test_thin_sample_explanation_is_honest_about_count() -> None:
- """The explanation for a thin-but-nonzero sample must say HOW MANY listings
- were found (not the generic 'ничего не найдено' text used for a genuine
- zero-listing case) — #4 in the task: explanation must match reality."""
+def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None:
+ """#4 (task spec): the explanation for a thin-but-real sample must read as
+ "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для
+ оценки по рынку" copy, and NOT the generic zero-analogs text."""
pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2
assert pr.explanation is not None
assert "2" in pr.explanation
- assert "недостаточно" in pr.explanation.lower()
- # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found.
+ assert "выборка мала" in pr.explanation.lower()
+ assert "точность снижена" in pr.explanation.lower()
+ assert "минимум для оценки по рынку" not in pr.explanation.lower()
assert "не найдено аналогов" not in pr.explanation.lower()
-def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None:
- """#4: once the ДКП fallback fires for a thin (not zero) sample, the
- explanation must not falsely claim 'рядом нет объявлений' — some WERE
- found, just not enough to trust."""
+def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None:
+ """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for
+ GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin
+ case, not the true-zero case, which still needs a fallback source."""
dkp_raw = {
- "count": 20,
- "low_ppm2": 40_000,
- "median_ppm2": 60_000,
- "high_ppm2": 80_000,
+ "count": 54,
+ "low_ppm2": 44_000,
+ "median_ppm2": 65_957,
+ "high_ppm2": 89_000,
"period_months": 12,
}
- pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw)
+ pr = _call(listings=[], dkp_raw=dkp_raw)
+ assert pr.median_ppm2 == 65_957.0
+ assert pr.n_analogs == 0
+ assert pr.confidence == "low"
assert pr.explanation is not None
- assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
+ assert "рядом нет актуальных объявлений" in pr.explanation.lower()
assert "сделкам росреестра" in pr.explanation.lower()
-def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None:
- """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs)
- without clearing `listings_clean` itself — the same-building anchor's own
- ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell
- "genuinely zero nearby listings" from "some nearby, just too few to trust
- as headline", and conflating the two was caught regressing
- test_estimator_split_corridor_1871.py during this change."""
+def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None:
+ """listings_clean stays intact (unchanged invariant — same-building anchor's
+ ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is
+ the REAL count (not zeroed) while listings_headline_thin_n still marks the
+ sample as thin for the low-reliability note upstream."""
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]))
- assert pr.n_analogs == 0
+ assert pr.n_analogs == 3
assert len(pr.listings_clean) == 3
assert pr.listings_headline_thin_n == 3
@@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None:
assert pr.listings_headline_thin_n == 0
+def test_repair_coefficient_now_applies_to_thin_sample() -> None:
+ """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a
+ thin sample because the headline was already zeroed (applying it would be a
+ no-op). Now that the real median is kept, the coefficient must apply."""
+ pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin
+ pr = _price_from_inputs(
+ listings=_lots([200_000.0, 210_000.0]),
+ area_m2=45.0,
+ rooms=2,
+ repair_state="excellent",
+ floor=5,
+ total_floors=9,
+ target_year=None,
+ analog_tier="W",
+ fallback_used=False,
+ area_widened=False,
+ anchor_comps=[],
+ anchor_tier_fetched=None,
+ dkp_raw=None,
+ imv_anchor=None,
+ imv_eval=None,
+ yandex_val_present=False,
+ cian_val_present=False,
+ ratio_resolver=lambda _appm2: (None, None),
+ quarter_index_lookup=lambda q: None,
+ quarter_indexes_lookup=lambda qs: {},
+ target_house_cadnum=None,
+ dadata_coarse=False,
+ geo=_geo(),
+ dadata_qc_geo=None,
+ )
+ assert (
+ pr.median_price != pr_no_repair.median_price
+ ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
+
+
# ─────────────────────────────────────────────────────────────────────────────
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
# ─────────────────────────────────────────────────────────────────────────────
@@ -261,24 +345,31 @@ def _serov_payload() -> Any:
def _run_estimate(
*,
- analogs: list[dict[str, Any]],
+ analogs: list[dict[str, Any]] | None = None,
dkp_raw: dict[str, Any] | None,
+ fetch_analogs_side_effect: Any = None,
+ payload: Any = None,
+ geo: GeocodeResult | None = None,
) -> Any:
from app.services.estimator import estimate_quality
db = MagicMock()
- payload = _serov_payload()
+ payload = payload or _serov_payload()
+ geo = geo or _geo()
+
+ fetch_analogs_kwargs: dict[str, Any] = (
+ {"side_effect": fetch_analogs_side_effect}
+ if fetch_analogs_side_effect is not None
+ else {"return_value": (list(analogs or []), False, "W")}
+ )
async def _run() -> Any:
with (
- patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
- patch(
- "app.services.estimator._fetch_analogs",
- return_value=(list(analogs), False, "W"),
- ),
+ patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs),
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
@@ -301,40 +392,47 @@ def _run_estimate(
return anyio.run(_run)
-def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None:
- """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor —
- median_price_rub must be 0 AND insufficient_data must be True TOGETHER
- (the AggregatedEstimate.insufficient_data computed_field invariant that
- stops a literal 0 ₽ reaching the user as a confident number)."""
+def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None:
+ """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always
+ returns the SAME 2 listings regardless of relaxation params (none of them
+ help) — median_price_rub must be the REAL non-zero 2-listing median,
+ insufficient_data False, n_analogs=2, confidence='low', reliability
+ 'very_low' (n<3), relaxations empty (nothing actually helped)."""
analogs = [
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
]
est = _run_estimate(analogs=analogs, dkp_raw=None)
- assert est.median_price_rub == 0
- assert est.insufficient_data is True
- assert est.n_analogs == 0
+ assert est.median_price_rub == round(205_000.0 * 45.0)
+ assert est.insufficient_data is False
+ assert est.n_analogs == 2
assert est.confidence == "low"
+ assert est.relaxations == []
+ assert est.reliability == "very_low"
-def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None:
- """The 2 thin listings must NOT be surfaced as `analogs` display cards while
- n_analogs reports 0 — that would be the same dishonesty (confident-looking
- UI) this whole gate exists to remove."""
+def test_e2e_thin_sample_display_cards_match_n_analogs() -> None:
+ """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin
+ sample — they must match n_analogs exactly (both = 2), never hidden."""
analogs = [
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
]
est = _run_estimate(analogs=analogs, dkp_raw=None)
- assert est.n_analogs == 0
- assert est.analogs == []
+ assert est.n_analogs == 2
+ assert len(est.analogs) == 2
def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
- """Live Серов repro (n=3 scraped listings, wide ДКП corridor available):
- headline must come from the deal corridor, not the noisy 3-listing median,
- and the estimate must be honestly non-'insufficient' (a real number, low
- confidence, deals-sourced)."""
+ """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) —
+ #oblast-E priority RESTORED: headline must come from the deal corridor,
+ not the noisy 3-listing median. Also proves the #4 task-spec requirements
+ layered on top of the restored priority: the estimate is honestly non-
+ 'insufficient' (a real number, low confidence), reliability is capped at
+ 'low' (not 'very_low' — a 54-deal corridor is real signal), the
+ relaxation label names the source switch, AND the 3 thin listings are
+ still shown as display cards (not discarded) even though they no longer
+ drive n_analogs/median."""
analogs = [
_make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"),
_make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"),
@@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
assert est.confidence == "low"
assert est.confidence_explanation is not None
assert "сделкам росреестра" in est.confidence_explanation.lower()
+ assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'"
+ assert "оценка по сделкам — мало объявлений рядом" in est.relaxations
+ assert len(est.analogs) == 3, "thin listings must still surface as display cards"
def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
"""Control (mirrors the Екатеринбург prod check in the PR): a sample that
clears the threshold is priced exactly as before — headline is the real
- listings median, all 5 analogs counted."""
+ listings median, all 5 analogs counted, no relaxations needed."""
analogs = [
_make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"),
_make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"),
@@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
assert est.median_price_per_m2 == 210_000
assert est.n_analogs == 5
assert est.insufficient_data is False
+ assert est.relaxations == []
+ assert est.reliability == "low" # n=5 falls in the 3..7 bucket
+
+
+def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None:
+ """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin
+ sample (studio-adjacent building, live prod repro Академика Парина 46/5) →
+ cascade retries with rooms IN (0,1) and finds a trustworthy sample there.
+ Asserts: studios pulled in, `relaxations` names it, real non-zero median,
+ reliability downgraded to 'low' (thin base sample)."""
+ from app.schemas.trade_in import TradeInEstimateInput
+
+ exact_rooms1 = [
+ _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0),
+ _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0),
+ ]
+ studio_pool = [
+ *exact_rooms1,
+ _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0),
+ _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0),
+ _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0),
+ ]
+
+ def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
+ if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1:
+ return list(studio_pool), False, "W"
+ return list(exact_rooms1), False, "W"
+
+ geo = GeocodeResult(
+ lat=56.838,
+ lon=60.595,
+ full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5",
+ provider="nominatim",
+ )
+ payload = TradeInEstimateInput(
+ address="ЕКБ, ул. Парина, 46/5",
+ area_m2=23.1,
+ rooms=1,
+ )
+
+ est = _run_estimate(
+ dkp_raw=None,
+ fetch_analogs_side_effect=_fetch_analogs_stub,
+ payload=payload,
+ geo=geo,
+ )
+
+ assert "учтены студии" in est.relaxations
+ assert est.median_price_rub > 0
+ assert est.reliability == "low"
+ assert est.n_analogs == 5
+
+
+def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None:
+ """#oblast-F step (d) contract: when the user explicitly picked radius_m
+ (#2044), the cascade must NOT auto-expand past it — mirrors the existing
+ radius-fallback contract above (no auto-expansion beyond user's choice)."""
+ from app.schemas.trade_in import TradeInEstimateInput
+
+ thin = [
+ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
+ _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
+ ]
+ payload = TradeInEstimateInput(
+ address="Серов, ул. Ленина, 5",
+ area_m2=45.0,
+ rooms=2,
+ floor=5,
+ total_floors=9,
+ city_hint="Серов",
+ radius_m=1500,
+ )
+ est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload)
+ assert not any("радиус расширен" in r for r in est.relaxations)
+ assert est.search_radius_m == 1500
diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py
new file mode 100644
index 00000000..d92a9ab9
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py
@@ -0,0 +1,216 @@
+"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога.
+
+Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений
+квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с
+кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой
+дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть
+«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену
+(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3,
+1.08, 1.09).
+
+Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py).
+Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что
+тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс
+всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над
+координатным резолвом квартала цели.
+"""
+
+from __future__ import annotations
+
+import os
+from contextlib import ExitStack
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import anyio
+
+from tests.test_estimator_quarter_index import (
+ _AREA,
+ _BASE_PPM2,
+ _make_fake_dadata,
+ _make_fake_geo_qi,
+ _make_listing_qi,
+ _make_payload_qi,
+)
+
+_TARGET_Q = "66:41:0204016"
+_NEIGH_CHEAP_Q = "66:41:0601022"
+_NEIGH_RICH_Q = "66:41:0601033"
+_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6}
+
+_UNPATCHED = object() # sentinel: координатный резолвер не подменяем
+
+
+def _run_estimate(
+ *,
+ analogs: list[dict[str, Any]],
+ dadata_cadnum: str | None,
+ coords_quarter: Any = _UNPATCHED,
+ coords_enabled: bool = True,
+):
+ """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES.
+
+ coords_quarter:
+ * `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок
+ отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели
+ неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) —
+ старый код подставит квартал аналога, на чём тест и падает.
+ * str / None — явная подмена резолвера.
+
+ coords_enabled — значение `estimate_quarter_from_coords_enabled` на время
+ прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины
+ включают его явно.
+ """
+ from app.services.estimator import estimate_quality, settings
+
+ db = MagicMock()
+ # «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на
+ # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала.
+ db.execute.return_value.first.return_value = None
+
+ dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None
+
+ def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
+ idx = _INDEXES.get(quarter_cad_number)
+ return (idx, 30) if idx is not None else None
+
+ def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
+ return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES}
+
+ async def _run():
+ stubs = [
+ patch(
+ "app.services.estimator.geocode",
+ new=AsyncMock(return_value=_make_fake_geo_qi()),
+ ),
+ patch(
+ "app.services.estimator.dadata_clean_address",
+ new=AsyncMock(return_value=dadata_obj),
+ ),
+ patch("app.services.estimator.match_house_readonly", return_value=None),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
+ patch(
+ "app.services.estimator._fetch_analogs",
+ return_value=(list(analogs), False, "W"),
+ ),
+ patch("app.services.estimator._fetch_deals", return_value=[]),
+ patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
+ patch(
+ "app.services.estimator._get_or_fetch_imv_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator._get_or_fetch_yandex_valuation_cached",
+ new=AsyncMock(return_value=None),
+ ),
+ patch(
+ "app.services.estimator.estimate_via_cian_valuation",
+ new=AsyncMock(return_value=None),
+ ),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
+ patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
+ patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index),
+ patch(
+ "app.services.estimator._lookup_quarter_indexes",
+ side_effect=_fake_lookup_indexes,
+ ),
+ ]
+ if coords_quarter is not _UNPATCHED:
+ stubs.append(
+ patch(
+ "app.services.estimator._lookup_target_quarter_by_coords",
+ return_value=coords_quarter,
+ )
+ )
+ stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled))
+ with ExitStack() as stack:
+ for stub in stubs:
+ stack.enter_context(stub)
+ return await estimate_quality(_make_payload_qi(), db)
+
+ return anyio.run(_run)
+
+
+def _analogs_two_quarters() -> list[dict[str, Any]]:
+ """5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м².
+
+ Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за
+ кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах.
+ Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет
+ все лоты в один.
+ """
+ cheap = [
+ _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i)
+ for i in range(5)
+ ]
+ rich = [
+ _make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i)
+ for i in range(5)
+ ]
+ return cheap + rich
+
+
+def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None:
+ """Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»).
+
+ КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед,
+ index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю
+ пишется «Учтена локация квартала (индекс цен квартала ×0.67)».
+ """
+ est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None)
+
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
+ assert "индекс цен квартала" not in (est.confidence_explanation or "")
+
+
+def test_target_quarter_from_own_coords_drives_correction() -> None:
+ """Квартал цели снят по ЕЁ координатам → поправка считается от него.
+
+ index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что
+ уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и
+ ×0.67 без всяких данных о цели.
+ """
+ est = _run_estimate(
+ analogs=_analogs_two_quarters(),
+ dadata_cadnum=None,
+ coords_quarter=_TARGET_Q,
+ )
+
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
+ assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "")
+
+
+def test_own_cadastre_wins_over_coords() -> None:
+ """Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся."""
+ analogs_cheap = [
+ _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i)
+ for i in range(5)
+ ]
+ with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
+ est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350")
+
+ assert spy.call_count == 0
+ # target 1.2 / avg 0.8 = 1.5
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
+
+
+def test_coords_resolver_dormant_by_default() -> None:
+ """Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще.
+
+ Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно:
+ точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ —
+ нет, поэтому включение — отдельное решение (см. config, критерий и дата).
+ """
+ with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy:
+ est = _run_estimate(
+ analogs=_analogs_two_quarters(),
+ dadata_cadnum=None,
+ coords_quarter=_UNPATCHED,
+ coords_enabled=False,
+ )
+
+ assert spy.call_count == 0
+ assert est.median_price_rub == round(_BASE_PPM2 * _AREA)
+ assert "индекс цен квартала" not in (est.confidence_explanation or "")
diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
new file mode 100644
index 00000000..5b824132
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
@@ -0,0 +1,250 @@
+"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя.
+
+Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт
+`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат
+дальше километра (самый дальний — 4381 м), а `GET /estimate/{id}` отдавал
+`search_radius_m: null` → фронт падал на превью-радиус 1 км и рисовал круг, за
+которым остаются его же пины. Там же вторая ложь: `confidence_explanation`
+утверждал «расширили радиус до 2 км» — константа старого 1км→2км fallback'а,
+хотя каскад #oblast-F ушёл до 5 км.
+
+Тесты падают на коде до этой правки: helper'ов не существует (ImportError),
+а объяснение прибито к «2 км».
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+_wp_mock = MagicMock()
+sys.modules.setdefault("weasyprint", _wp_mock)
+sys.modules.setdefault("weasyprint.CSS", _wp_mock)
+sys.modules.setdefault("weasyprint.HTML", _wp_mock)
+
+import pytest # noqa: E402
+from fastapi import FastAPI # noqa: E402
+from fastapi.testclient import TestClient # noqa: E402
+
+from app.services.estimator import ( # noqa: E402
+ DEFAULT_RADIUS_M,
+ _compute_confidence,
+ radius_from_relaxations,
+ radius_relaxation_label,
+ rehydrate_search_radius_m,
+)
+
+_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202"
+
+
+# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ──
+
+
+def test_radius_label_roundtrips() -> None:
+ assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000
+ assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000
+ # прод-строка дословно
+ assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000
+
+
+def test_radius_label_absent_or_malformed_gives_none() -> None:
+ assert radius_from_relaxations(None) is None
+ assert radius_from_relaxations([]) is None
+ assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None
+ assert radius_from_relaxations(["радиус расширен до пяти км"]) is None
+
+
+# ── реконструкция радиуса для сохранённой оценки ──────────────────────────────
+
+
+def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None:
+ """Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу."""
+ assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000
+
+
+def test_rehydrate_falls_back_to_analog_extent() -> None:
+ """Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины."""
+ assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428
+
+
+def test_rehydrate_never_below_default_radius() -> None:
+ """Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м."""
+ assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M
+
+
+def test_rehydrate_without_distances_stays_none() -> None:
+ """Нечего утверждать — фронт честно падает на выбор пользователя (как раньше)."""
+ assert rehydrate_search_radius_m([], []) is None
+ assert rehydrate_search_radius_m(None, [None, None]) is None
+
+
+# ── объяснение называет тот радиус, по которому реально искали ────────────────
+
+
+def _confidence(radius_used_m: int | None) -> str:
+ _, explanation = _compute_confidence(
+ 12,
+ 100_000.0,
+ 95_000.0,
+ 105_000.0,
+ True, # fallback_radius_used
+ False,
+ listings=[{"address": f"дом {i}"} for i in range(12)],
+ radius_used_m=radius_used_m,
+ )
+ return explanation
+
+
+def test_explanation_names_actual_radius_not_hardcoded_2km() -> None:
+ text = _confidence(5000)
+ assert "5 км" in text
+ assert "2 км" not in text
+
+
+def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None:
+ assert "2 км" in _confidence(2000)
+ # не-круглый радиус не притворяется километрами
+ assert "1500 м" in _confidence(1500)
+
+
+# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ──────────
+
+
+def _prod_shaped_row() -> SimpleNamespace:
+ """Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра)."""
+ analog = {
+ "address": "Екатеринбург, ул. Советская, 51",
+ "area_m2": 44.0,
+ "rooms": 2,
+ "floor": 3,
+ "total_floors": 9,
+ "price_rub": 6_000_000,
+ "price_per_m2": 136_000,
+ "listing_date": None,
+ "days_on_market": None,
+ "source": "cian",
+ "distance_m": 4381,
+ }
+ return SimpleNamespace(
+ id=_ESTIMATE_ID,
+ median_price=6_000_000,
+ range_low=5_500_000,
+ range_high=6_500_000,
+ median_price_per_m2=136_000,
+ confidence="low",
+ confidence_explanation="Найдено 42 аналогов",
+ n_analogs=42,
+ analogs=[analog, {**analog, "distance_m": 1200}],
+ actual_deals=[],
+ sources_used=["cian"],
+ data_freshness_minutes=15,
+ expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
+ retain_until=None,
+ address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51",
+ lat=56.8519,
+ lon=60.6122,
+ area_m2=44.0,
+ rooms=2,
+ floor=3,
+ total_floors=9,
+ year_built=1975,
+ house_type=None,
+ repair_state=None,
+ has_balcony=None,
+ canonical_address=None,
+ house_cadnum=None,
+ house_fias_id=None,
+ dadata_qc_geo=None,
+ dadata_metro=[],
+ expected_sold_price=None,
+ expected_sold_range_low=None,
+ expected_sold_range_high=None,
+ expected_sold_per_m2=None,
+ asking_to_sold_ratio=None,
+ ratio_basis=None,
+ created_by="praktika",
+ created_at=datetime.now(tz=UTC),
+ relaxations=["радиус расширен до 5000 м"],
+ reliability="low",
+ )
+
+
+@pytest.fixture()
+def _light_estimator_stub():
+ """Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию.
+
+ Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить
+ геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся
+ настоящий — его поведение и проверяем.
+ """
+ real_estimator = sys.modules.get("app.services.estimator")
+ stub = SimpleNamespace(
+ _qc_geo_to_precision=lambda _qc: None,
+ _fetch_price_trend=lambda *a, **k: None,
+ _fetch_dkp_corridor=lambda *a, **k: None,
+ _fetch_house_imv_anchor=lambda *a, **k: None,
+ _resolve_target_city=lambda *a, **k: None,
+ _cv_from_ppm2=lambda *a, **k: None,
+ _source_counts=lambda *a, **k: {},
+ _canonical_sources=lambda *a, **k: ["cian"],
+ rehydrate_search_radius_m=rehydrate_search_radius_m,
+ )
+ sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
+ yield stub
+ if real_estimator is not None:
+ sys.modules["app.services.estimator"] = real_estimator
+ else:
+ sys.modules.pop("app.services.estimator", None)
+
+
+@pytest.fixture(autouse=True)
+def _restore_get_role():
+ from app.core import auth as auth_mod
+
+ original = auth_mod.get_role
+ yield
+ auth_mod.get_role = original
+
+
+def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None:
+ """Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км."""
+ from app.api.v1 import trade_in as trade_in_module
+ from app.core import auth as auth_mod
+ from app.core.db import get_db
+
+ app = FastAPI()
+ app.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
+
+ row = _prod_shaped_row()
+ db = MagicMock()
+
+ def _execute(clause, params=None, *_a, **_k):
+ sql = getattr(clause, "text", str(clause))
+ result = MagicMock()
+ result.fetchone.return_value = row if "SELECT id, median_price" in sql else None
+ return result
+
+ db.execute.side_effect = _execute
+
+ def _override_db():
+ yield db
+
+ app.dependency_overrides[get_db] = _override_db
+ auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
+
+ resp = TestClient(app).get(
+ f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
+ headers={"X-Authenticated-User": "praktika"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["search_radius_m"] == 5000
+ # круг обязан вмещать пины, которые сам же показывает
+ assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"])
+ # вход не выдаём за результат: radius_m не персистится — «просили» неизвестно
+ assert body["requested_radius_m"] is None
diff --git a/tradein-mvp/backend/tests/test_geocoder_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py
new file mode 100644
index 00000000..b6626d94
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py
@@ -0,0 +1,277 @@
+"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ».
+
+Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры.
+Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не
+находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой
+«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк,
+2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`,
+6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов.
+
+Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город)
+и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После
+ответа различимы: провайдер сам пишет, какой НП он использовал.
+
+Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные).
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
+
+import pytest
+
+from app.services.geocoder import (
+ GeocodeResult,
+ GeocodeSuggestion,
+ _city_substituted,
+ _claimed_locality,
+ geocode,
+)
+
+# ── _claimed_locality: какой НП назван в тексте ──────────────────────────────
+
+
+@pytest.mark.parametrize(
+ "address,expected",
+ [
+ # НП назван явно
+ ("реж, ленина", "реж"),
+ ("арамиль, красноармейская ул, 118дк1", "арамиль"),
+ ("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось
+ # Уровни выше НП пропускаются, НП находится дальше
+ ("свердловская обл., г.о. рефтинский", "рефтинский"),
+ ("россия, свердловская область, реж, ленина 5", "реж"),
+ # НП НЕ назван — основной трафик формы оценки, инвариант не применяется
+ ("малышева 30", None),
+ ("ул. титова/монтерская/смоленская, стр. 2", None),
+ ("улица декабристов, 16-18литж · р-н октябрьский", None),
+ ("мкр-н широкая речка, ул. анатолия муранова,18", None),
+ # Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним
+ ("малышева, мопра", "малышева"),
+ ],
+)
+def test_claimed_locality(address: str, expected: str | None) -> None:
+ assert _claimed_locality(address) == expected
+
+
+# ── _city_substituted: подмена vs честный ответ ──────────────────────────────
+
+
+def _res(full_address: str, lat: float, lon: float) -> GeocodeResult:
+ return GeocodeResult(
+ lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact"
+ )
+
+
+@pytest.mark.parametrize(
+ "address,full_address,lat,lon",
+ [
+ # Живые записи прод-кэша из тела #2590 (все provider=yandex)
+ (
+ "реж, ленина",
+ "Россия, Свердловская область, Екатеринбург, улица Ленина",
+ 56.83953,
+ 60.61244,
+ ),
+ (
+ "реж, краснофлотцев",
+ "Россия, Свердловская область, Екатеринбург, улица Краснофлотцев",
+ 56.88996,
+ 60.63184,
+ ),
+ (
+ "арамиль, заводская",
+ "Россия, Свердловская область, Екатеринбург, Заводская улица",
+ 56.83043,
+ 60.55170,
+ ),
+ (
+ "свердловская обл., г.о. рефтинский",
+ "Россия, Свердловская область, Екатеринбург",
+ 56.83744,
+ 60.59764,
+ ),
+ # Города вне списка 37 и без структурной приставки — дыра, оставленная #2589
+ (
+ "талица, кузнецова",
+ "Россия, Свердловская область, Екатеринбург, улица Кузнецова",
+ 56.84226,
+ 60.64557,
+ ),
+ (
+ "верхотурье, герцена",
+ "Россия, Свердловская область, Екатеринбург, улица Герцена",
+ 56.744397,
+ 60.677299,
+ ),
+ # Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от
+ # улицы Советской, куда провайдер поставил точку (живая запись прода).
+ (
+ "пос. кедровка, советская ул.,5",
+ "Россия, Свердловская область, Екатеринбург, Советская улица, 5",
+ 56.8506,
+ 60.6282,
+ ),
+ ],
+)
+def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None:
+ assert _city_substituted(address, _res(full_address, lat, lon)) is True
+
+
+@pytest.mark.parametrize(
+ "address,full_address,lat,lon",
+ [
+ # Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address.
+ # Тот же вход, что и в подмене выше: разделяет их не география, а
+ # сохранность топонима (граница из #2777 — посёлки не ловятся
+ # «городским» признаком, и здесь ловить их и не нужно).
+ (
+ "пос. кедровка, советская ул.,5",
+ "5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
+ 56.9902,
+ 60.7166,
+ ),
+ (
+ "шувакиш, ленина 4",
+ "4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург",
+ 56.9295,
+ 60.4938,
+ ),
+ # НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза
+ (
+ "малышева 30",
+ "Россия, Свердловская область, Екатеринбург, улица Малышева, 30",
+ 56.8389,
+ 60.6057,
+ ),
+ # Явный Екатеринбург в адресе — не подмена по определению
+ ("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686),
+ ("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943),
+ # Честный ответ по другому городу области: провайдер назвал НП, координаты
+ # в его собственном bbox — под инвариант не подпадает
+ ("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813),
+ # Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит
+ # внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено
+ (
+ "режевской, ленина",
+ "Россия, Свердловская область, Берёзовский, улица Ленина",
+ 56.9088,
+ 60.8107,
+ ),
+ # Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не
+ # является подменой города
+ (
+ "малышева, мопра",
+ "Россия, Свердловская область, Екатеринбург, улица Малышева",
+ 56.8389,
+ 60.6057,
+ ),
+ # Тот же НП, но за пределами ЕКБ — инвариант молчит
+ ("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897),
+ ],
+)
+def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None:
+ assert _city_substituted(address, _res(full_address, lat, lon)) is False
+
+
+# ── geocode(): сквозной эффект ───────────────────────────────────────────────
+
+
+@contextlib.contextmanager
+def _tiers(*, cached=None, nominatim=None, geoportal=None):
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached))
+ stack.enter_context(patch("app.services.geocoder._cache_put"))
+ stack.enter_context(
+ patch("app.services.geocoder._geoportal_house_match", return_value=geoportal)
+ )
+ stack.enter_context(
+ patch("app.services.geocoder._cadastral_house_match", return_value=None)
+ )
+ stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
+ stack.enter_context(
+ patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim))
+ )
+ stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()))
+ yield
+
+
+async def test_geocode_downgrades_substituted_result() -> None:
+ """«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact.
+
+ Координаты остаются (честная деградация, не отказ), но confidence падает до
+ `locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse`
+ включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу
+ `geo_precision='city'`, что исключает его из пула аналогов.
+ """
+ db = MagicMock()
+ substituted = GeocodeResult(
+ lat=56.83953,
+ lon=60.61244,
+ full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5",
+ provider="nominatim",
+ confidence="exact",
+ )
+ with _tiers(nominatim=substituted):
+ result = await geocode("Реж, Ленина 5", db)
+
+ assert result is not None
+ assert result.confidence == "locality"
+ assert result.city_ambiguous is True
+ assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены
+
+
+async def test_geocode_poisoned_cache_hit_downgraded() -> None:
+ """Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже
+ НЕ как точная — без единого DELETE в `geocode_cache`."""
+ db = MagicMock()
+ poisoned = GeocodeResult(
+ lat=56.83744,
+ lon=60.59764,
+ full_address="Россия, Свердловская область, Екатеринбург",
+ provider="cache",
+ confidence="exact",
+ )
+ with _tiers(cached=poisoned):
+ result = await geocode("Арамиль, Красноармейская ул, 118дк1", db)
+
+ assert result is not None
+ assert result.confidence == "locality"
+ assert result.city_ambiguous is True
+
+
+async def test_geocode_ekb_address_unaffected() -> None:
+ """Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города)
+ остаётся `exact` — инвариант не трогает основной трафик."""
+ db = MagicMock()
+ hit = GeocodeSuggestion(
+ label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house"
+ )
+ with _tiers(geoportal=hit):
+ result = await geocode("Екатеринбург, Малышева 30", db)
+
+ assert result is not None
+ assert result.confidence == "exact"
+ assert result.city_ambiguous is False
+
+
+async def test_geocode_settlement_inside_ekb_kept_exact() -> None:
+ """Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не
+ отбраковывается — граница, о которой предупреждает #2777."""
+ db = MagicMock()
+ honest = GeocodeResult(
+ lat=56.9902,
+ lon=60.7166,
+ full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
+ provider="nominatim",
+ confidence="exact",
+ )
+ with _tiers(nominatim=honest):
+ result = await geocode("пос. Кедровка, Советская ул., 5", db)
+
+ assert result is not None
+ assert result.confidence == "exact"
diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py
index 63db7822..4355e323 100644
--- a/tradein-mvp/backend/tests/test_house_dedup_merge.py
+++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py
@@ -18,6 +18,7 @@ import inspect
import os
import re
from pathlib import Path
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -486,9 +487,13 @@ class _FakeDB:
mapping_rows: list[_Row],
step_rowcount: int = 1,
fk_children: dict[str, str] | None = None,
+ residual: dict[str, int] | None = None,
+ residual_raises: bool = False,
):
self._mapping_rows = mapping_rows
self._step_rowcount = step_rowcount
+ self._residual = residual
+ self._residual_raises = residual_raises
self._mapping_served = False
# The catalog the FK-child guard reads; defaults to the real live set.
self._fk_children = _FK_CHILDREN if fk_children is None else fk_children
@@ -503,6 +508,12 @@ class _FakeDB:
return _FakeResult()
if "FROM pg_constraint" in sql:
return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()])
+ if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes
+ if self._residual_raises:
+ raise RuntimeError("census exploded")
+ if self._residual is None:
+ return _FakeResult(rows=[])
+ return _FakeResult(rows=[SimpleNamespace(**self._residual)])
if "SELECT loser_id, keeper_id, norm_address" in sql:
# The service now runs TWO passes (fias, then canon). Model «fias pass found the
# duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards.
@@ -550,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None:
assert db.rollbacks == 1
+# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ──
+
+
+def test_residual_census_reuses_the_canon_mapping_prelude() -> None:
+ """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход.
+
+ Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с
+ проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и
+ ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи
+ нет вовсе.
+ """
+ residual = _flat(str(hdm._RESIDUAL_SQL.text))
+ prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR))
+ assert prelude in residual
+ assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
+
+
+def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None:
+ """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя.
+
+ Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он
+ высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как
+ «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только
+ то, что и так слилось.
+ """
+ residual = _flat(str(hdm._RESIDUAL_SQL.text))
+ for bucket in (
+ "AS residual_rows",
+ "AS residual_listings",
+ "AS residual_no_geom",
+ "AS residual_far",
+ "AS residual_cross_fias",
+ "AS residual_mergeable",
+ ):
+ assert bucket in residual, bucket
+ # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк.
+ assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual
+ assert set(hdm.DedupMergeResult().to_counters()) >= {
+ "residual_rows",
+ "residual_listings",
+ "residual_no_geom",
+ "residual_far",
+ "residual_cross_fias",
+ "residual_mergeable",
+ }
+
+
+def test_residual_counters_reported_even_when_nothing_merged() -> None:
+ """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число."""
+ db = _FakeDB(
+ mapping_rows=[],
+ residual={
+ "residual_rows": 963,
+ "residual_listings": 1765,
+ "residual_no_geom": 326,
+ "residual_far": 568,
+ "residual_cross_fias": 8,
+ "residual_mergeable": 61,
+ },
+ )
+ out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
+ assert out["losers_deleted"] == 0
+ assert out["residual_rows"] == 963
+ assert out["residual_listings"] == 1765
+ assert out["residual_no_geom"] == 326
+ assert out["residual_far"] == 568
+ assert out["residual_cross_fias"] == 8
+ assert out["residual_mergeable"] == 61
+
+
+def test_residual_census_failure_never_breaks_the_merge() -> None:
+ """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит."""
+ db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True)
+ out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type]
+ assert out["losers_deleted"] == 1
+ assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано
+ assert db.commits == 1
+
+
def test_real_merge_commits() -> None:
"""dry_run=False with dups → exactly one commit, no rollback."""
rows = [_Row(2, 1, "ул. мира, 10")]
diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
index c36aa891..98f057aa 100644
--- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
+++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
@@ -138,7 +138,11 @@ async def test_unknown_house_type_skips_request_and_marks_house() -> None:
patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval,
patch.object(hib, "_mark_status") as mock_mark,
):
- db.execute.return_value.mappings.return_value.all.return_value = houses
+ # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
+ # затем основную по only_status. Фейк отвечает одним и тем же списком на любой
+ # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
+ # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
+ db.execute.return_value.mappings.return_value.all.side_effect = [[], houses]
result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
mock_eval.assert_not_called()
diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
new file mode 100644
index 00000000..b414aefc
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
@@ -0,0 +1,267 @@
+"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную.
+
+Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон
+(`only_status`, дефолт 'pending'). Возможность повтора существовала и была
+задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026)
+её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине,
+которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в
+'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026.
+
+Что проверяем — по существу, а не по настройке:
+ * дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами
+ (тот же вызов, что делает расписание) — на старом коде он не попадает;
+ * pending при этом из пакета НЕ вытесняется;
+ * дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а
+ ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его;
+ * дом без пригодных параметров помечается no_params БЕЗ запроса к площадке.
+
+Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие
+строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml
+поднимает Postgres и накатывает схему).
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from app.services import house_imv_backfill as hib
+
+# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py).
+_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь
+_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём
+_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью
+_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса
+_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS)
+
+
+def _live_session() -> Any | None:
+ """Session к достижимому не-заглушечному Postgres, иначе None."""
+ try:
+ from sqlalchemy import create_engine, text
+ from sqlalchemy.orm import sessionmaker
+
+ dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
+ if not dsn or "localhost:5432/test" in dsn:
+ return None
+ engine = create_engine(dsn, future=True)
+ conn = engine.connect()
+ conn.execute(text("SELECT 1"))
+ conn.close()
+ return sessionmaker(bind=engine, future=True)()
+ except Exception:
+ return None
+
+
+def _seed(db: Any) -> None:
+ from sqlalchemy import text as _t
+
+ # url/source/ext_house_id — NOT NULL в houses (002_core_tables).
+ db.execute(
+ _t(
+ "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, "
+ " imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES "
+ "(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, "
+ " 'transient_error', 0, NOW() - INTERVAL '10 days'),"
+ "(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, "
+ " 'transient_error', :cap, NOW() - INTERVAL '10 days'),"
+ "(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, "
+ " 'pending', 0, NULL),"
+ "(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, "
+ " 'pending', 0, NULL)"
+ ),
+ {
+ "s": _H_STUCK,
+ "x": _H_EXHAUSTED,
+ "p": _H_PENDING,
+ "n": _H_NO_PARAMS,
+ # Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана
+ # работать и на СТАРОМ коде, иначе красный прогон получится про
+ # отсутствующий атрибут, а не про застрявший дом. Синхронность
+ # литерала и константы стережёт test_transient_attempts_counter_*.
+ "cap": 3,
+ },
+ )
+ # Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS.
+ # source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE.
+ db.execute(
+ _t(
+ "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, "
+ " house_id_fk, rooms, area_m2, floor, total_floors, house_type) "
+ "VALUES "
+ "(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, "
+ " 2, 52.0, 3, 9, 'brick'),"
+ "(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, "
+ " 2, 52.0, 3, 9, 'brick'),"
+ "(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, "
+ " 2, 52.0, 3, 9, 'brick')"
+ ),
+ {
+ "l1": 910601,
+ "l2": 910602,
+ "l3": 910603,
+ "s": _H_STUCK,
+ "x": _H_EXHAUSTED,
+ "p": _H_PENDING,
+ },
+ )
+ db.commit()
+
+
+def _cleanup(db: Any) -> None:
+ from sqlalchemy import text as _t
+
+ db.rollback()
+ db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)"))
+ db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)})
+ db.commit()
+ db.close()
+
+
+async def _run(db: Any, **kwargs: Any) -> Any:
+ """Прогнать backfill с замоканным вызовом площадки.
+
+ Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден»,
+ поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен —
+ проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать
+ сайдкар.
+ """
+ err = hib.IMVAddressNotFoundError("address not found (test)")
+ with (
+ patch.object(hib.settings, "avito_imv_use_browser_fetcher", False),
+ patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)),
+ ):
+ return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None:
+ """Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом.
+
+ На старом коде выборка — `WHERE imv_status = :status` с единственным
+ значением 'pending', поэтому дом остаётся в 'transient_error' навсегда.
+ """
+ from sqlalchemy import text as _t
+
+ db = _live_session()
+ assert db is not None
+ if db.execute(_t("SELECT count(*) FROM houses")).scalar():
+ pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
+ try:
+ _seed(db)
+
+ # Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию.
+ result = await _run(db, batch_size=50)
+
+ picked = {
+ r.id: r.imv_status
+ for r in db.execute(
+ _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
+ {"ids": list(_IDS)},
+ )
+ }
+ assert picked[_H_STUCK] != "transient_error", (
+ "дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет "
+ f"обычного прогона; фактически статус {picked[_H_STUCK]!r} "
+ f"(в пакет прогона попало домов: {result.checked})"
+ )
+ # pending не вытеснен повтором.
+ assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете"
+ # Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл.
+ assert picked[_H_EXHAUSTED] == "transient_error"
+ assert result.retried == 1
+ # Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен.
+ assert picked[_H_NO_PARAMS] == "no_params"
+ assert result.premarked == 1
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+async def test_explicit_only_status_still_takes_exhausted_houses() -> None:
+ """Явный only_status — ручной запрос оператора: лимит попыток его не режет."""
+ from sqlalchemy import text as _t
+
+ db = _live_session()
+ assert db is not None
+ if db.execute(_t("SELECT count(*) FROM houses")).scalar():
+ pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
+ try:
+ _seed(db)
+
+ result = await _run(db, batch_size=50, only_status="transient_error")
+
+ statuses = {
+ r.id: r.imv_status
+ for r in db.execute(
+ _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
+ {"ids": list(_IDS)},
+ )
+ }
+ assert (
+ statuses[_H_EXHAUSTED] != "transient_error"
+ ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит"
+ # Автоповтора поверх явного запроса нет: pending не тронут.
+ assert statuses[_H_PENDING] == "pending"
+ assert result.retried == 0
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+async def test_transient_attempts_counter_only_counts_transient() -> None:
+ """Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем.
+
+ Без верхней границы у повтора не было бы выхода; без обнуления успехом дом,
+ однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор.
+ """
+ from sqlalchemy import text as _t
+
+ db = _live_session()
+ assert db is not None
+ if db.execute(_t("SELECT count(*) FROM houses")).scalar():
+ pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
+ try:
+ _seed(db)
+ assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован"
+
+ def _attempts() -> int:
+ return int(
+ db.execute(
+ _t("SELECT imv_transient_attempts FROM houses WHERE id = :i"),
+ {"i": _H_STUCK},
+ ).scalar()
+ )
+
+ hib._mark_status(db, _H_STUCK, "transient_error", "boom")
+ assert _attempts() == 1
+ hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден")
+ assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают"
+ db.execute(
+ _t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"),
+ {"i": _H_STUCK},
+ )
+ db.commit()
+ assert _attempts() == 0
+ finally:
+ _cleanup(db)
+
+
+def test_retry_queue_is_not_bound_to_only_status() -> None:
+ """Статика (бежит и без БД): retry-выборка не зависит от параметра only_status.
+
+ Ровно этот дефект и застрял на проде: единственный путь к 'transient_error'
+ шёл через значение, которое никто не передавал.
+ """
+ retry_sql = str(hib._RETRY_QUEUE_SQL.text)
+ assert "imv_status = 'transient_error'" in retry_sql
+ assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status"
+ assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход"
+ # psycopg v3: только CAST(:x AS type), никогда :x::type.
+ for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)):
+ assert "::" not in sql
diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py
index f0a0c371..e0066101 100644
--- a/tradein-mvp/backend/tests/test_matching.py
+++ b/tradein-mvp/backend/tests/test_matching.py
@@ -844,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a():
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
+# ---------------------------------------------------------------------------
+# match_or_create_house — sweep-city guard for BARE oblast cards (#2777)
+# ---------------------------------------------------------------------------
+
+
+def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups():
+ """RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города
+ в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один
+ нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка
+ села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у
+ развёртки ЕСТЬ — он же пишется в listings.city — и теперь доезжает до стража."""
+ from app.services.matching.houses import match_or_create_house
+
+ db = _make_db(
+ [
+ None, # pg_advisory_xact_lock
+ None, # house_sources miss (Tier 1)
+ # Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат)
+ {"id": 2777}, # INSERT RETURNING id (New house)
+ None, # _upsert_house_source
+ None, # _insert_alias
+ ]
+ )
+ house_id, conf, method = match_or_create_house(
+ db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов"
+ )
+ assert (house_id, conf, method) == (2777, 1.0, "new")
+ sqls = _executed_sqls(db)
+ assert not any(
+ "fingerprint = :fp" in s for s in sqls
+ ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»"
+ assert not any("normalized_address = :na" in s for s in sqls)
+
+
+def test_sweep_city_ekb_keeps_tier2a_dedup():
+ """Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит,
+ Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35."""
+ from app.services.matching.houses import match_or_create_house
+
+ db = _make_db(
+ [
+ None, # pg_advisory_xact_lock
+ None, # house_sources miss
+ {"house_id": 55}, # Tier 2a fingerprint HIT
+ None, # _upsert_house_source
+ None, # _insert_alias
+ ]
+ )
+ house_id, conf, method = match_or_create_house(
+ db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург"
+ )
+ assert (house_id, conf, method) == (55, 0.9, "fingerprint")
+ assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
+
+
+def test_sweep_city_ignored_when_card_has_coords():
+ """Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе,
+ у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак — значит ломать
+ смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание)."""
+ from app.services.matching.houses import match_or_create_house
+
+ db = _make_db(
+ [
+ None, # pg_advisory_xact_lock
+ None, # house_sources miss
+ {"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе)
+ None, # _upsert_house_source
+ None, # _insert_alias
+ ]
+ )
+ house_id, conf, method = match_or_create_house(
+ db,
+ "avito",
+ "ext-2777-coords",
+ address="ул. Кирова,4",
+ lat=59.60,
+ lon=60.58,
+ city="Верхняя Пышма",
+ )
+ assert (house_id, conf, method) == (66, 0.9, "fingerprint")
+ assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
+
+
+def test_sweep_city_guard_covers_cities_outside_the_token_list():
+ """Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет)
+ всё равно не имеет права садиться на чужой алиас. Признак — имя города от развёртки,
+ а не токен, вычитанный из адреса."""
+ from app.services.matching.houses import match_or_create_house
+
+ db = _make_db(
+ [
+ None, # pg_advisory_xact_lock
+ None, # house_sources miss
+ {"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены
+ None, # _upsert_house_source
+ None, # _insert_alias
+ ]
+ )
+ house_id, _conf, method = match_or_create_house(
+ db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда"
+ )
+ assert (house_id, method) == (2778, "new")
+ assert not any("fingerprint = :fp" in s for s in _executed_sqls(db))
+
+
# ---------------------------------------------------------------------------
# match_or_create_listing — mock DB tier routing
# ---------------------------------------------------------------------------
diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
index 80bca572..b16be85b 100644
--- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
+++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
@@ -107,3 +107,27 @@ def test_house_key_never_accepts_flat_cadastre() -> None:
assert (
"cad = building_cadastral_number\n" in src
), "в ключ дома вернулся фолбэк на кадастр квартиры"
+
+
+def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None:
+ """Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777).
+
+ Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его
+ некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой
+ вызывающий пути создания домов; город он получает из `save_listings`, который его же
+ пишет в `listings.city`.
+ """
+ from scraper_kit.base import _link_listing_to_house, save_listings
+
+ assert "city" in _params(match_or_create_house)
+
+ hook_src = inspect.getsource(_link_listing_to_house)
+ assert "city=city" in hook_src, (
+ "_link_listing_to_house перестал передавать город в матчер — страж #2777 снова "
+ "недостижим, а межгородская склейка молча вернётся"
+ )
+ # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать
+ # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен.
+ assert "city=lot_city" in inspect.getsource(
+ save_listings
+ ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён"
diff --git a/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py
new file mode 100644
index 00000000..6a8ce9b3
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py
@@ -0,0 +1,79 @@
+"""Static guards for migration 257 (#2838): лечение yandex source_url.
+
+Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда:
+
+ 1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО
+ `CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с
+ `_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление
+ транзитивное: поменяют форму у продюсера — красным станет тот тест, поменяют в
+ миграции — этот.
+ 2. Отбор — теми же константами, что у очереди (#2838), а не «похожими» regexp'ами.
+ 3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами — таблица отката
+ наполнится уже канонизированными адресами, и откат станет no-op'ом, который
+ выглядит рабочим.
+ 4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from app.tasks.yandex_detail_backfill import (
+ CANONICAL_URL_SQL,
+ OFFER_ID_PATTERN,
+ OFFER_URL_PATTERN,
+)
+
+_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "data"
+ / "sql"
+ / "257_listings_backfill_yandex_source_url.sql"
+)
+
+
+def _executable_sql() -> str:
+ """SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд."""
+ lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()]
+ return "\n".join(line for line in lines if line.strip())
+
+
+def _flat(text: str) -> str:
+ return re.sub(r"\s+", " ", text).strip()
+
+
+def test_migration_exists() -> None:
+ assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}"
+
+
+def test_canonical_url_matches_producer_formula() -> None:
+ """Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер)."""
+ assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql())
+
+
+def test_selects_rows_by_the_same_constants_as_the_queue() -> None:
+ """Отбор — константами очереди #2838, иначе миграция чинит не то множество."""
+ sql = _executable_sql()
+ assert f"'{OFFER_URL_PATTERN}'" in sql
+ assert f"'{OFFER_ID_PATTERN}'" in sql
+ assert "source = 'yandex'" in sql
+
+
+def test_backup_is_written_before_the_update() -> None:
+ sql = _executable_sql()
+ assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings")
+
+
+def test_transactional_and_lock_timeout_after_begin() -> None:
+ sql = _executable_sql()
+ assert "BEGIN;" in sql and "COMMIT;" in sql
+ assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout")
+ assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings")
+
+
+def test_touches_only_source_url_and_deletes_nothing() -> None:
+ sql = _executable_sql().upper()
+ for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"):
+ assert forbidden not in sql, forbidden
+ assert sql.count("SET SOURCE_URL") == 1
diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py
index da5722ec..7d6d21ed 100644
--- a/tradein-mvp/backend/tests/test_pdf_security.py
+++ b/tradein-mvp/backend/tests/test_pdf_security.py
@@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None:
assert "РЫНОК КВАРТИР" in html
+# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ────────
+# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py
+# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list
+# (thin listings kept as reference cards) used to print "0 шт." above a
+# non-empty examples table — a client-visible contradiction that leaked into
+# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_
+# listings_note_html / _reliability_note_html.
+
+
+def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None:
+ """The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' —
+ falls back to the actually-shown population (3) and adds an honest
+ deals-sourced footnote."""
+ analogs = [
+ _analog(address="ул. Льва Толстого, 8А"),
+ _analog(address="ул. Кирова, 4"),
+ _analog(address="ул. Льва Толстого, 34"),
+ ]
+ est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "0 шт." not in html
+ assert "3 шт." in html
+ assert "Оценка построена по зарегистрированным сделкам Росреестра" in html
+ assert "почти нет" in html
+
+
+def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None:
+ """Control: genuinely zero listings (no cards to show either) — '0 шт.' is
+ honest here, and the deals-sourced footnote (which explains a MISMATCH)
+ must NOT appear since there is nothing to reconcile."""
+ est = _estimate(n_analogs=0, analogs=[])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "0 шт." in html
+ assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html
+
+
+def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None:
+ """Control/regression guard for the max() choice: a healthy sample where
+ n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's
+ own top-10 cap) must keep printing the full honest count (15 шт.), NOT
+ silently understate it to len(analogs) (10 шт.)."""
+ analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)]
+ est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "15 шт." in html
+ assert "10 шт." not in html
+
+
+def test_listings_page_relaxations_warning_shown_with_labels() -> None:
+ """relaxations non-empty → warning block present, names the labels, and
+ reliability != 'ok' — mirrors what the web LowConfidenceBanner already
+ shows (see AggregatedEstimate docstring)."""
+ est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low")
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "Точность оценки снижена." in html
+ assert "учтены студии" in html
+ assert "радиус расширен до 3000 м" in html
+
+
+def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None:
+ """reliability != 'ok' but relaxations is empty (cascade couldn't grow a
+ thin sample, estimator.py #oblast-F) → warning block still shown, with a
+ fallback sentence (not an empty label list)."""
+ est = _estimate(n_analogs=2, reliability="very_low", relaxations=[])
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "Точность оценки снижена." in html
+ assert "небольшой выборке" in html
+
+
+def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None:
+ """Control: the common/unrelaxed case (reliability='ok' default, no
+ relaxations) — no warning block at all, byte-identical to the report
+ before these fields existed."""
+ est = _estimate()
+ assert est.reliability == "ok"
+ assert est.relaxations == []
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ assert "Точность оценки снижена." not in html
+
+
def test_build_deals_page_none_year_built_no_crash() -> None:
snap = dict(_SNAPSHOT)
snap["year_built"] = None
diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py
index ee2fface..813d78a2 100644
--- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py
+++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py
@@ -156,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
with (
patch.object(admin_module, "_probe_browser_health", fake_browser_health),
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
- patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]),
+ # #2830: узел резолвится из пула по (db, source), а не из статичного env
+ patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]),
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
):
r = client.get("/api/v1/admin/scraper/health")
@@ -207,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None:
with (
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
- patch.object(admin_module, "_provider_proxy_url", lambda s: None),
+ patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None),
):
r = client.get("/api/v1/admin/scraper/health")
diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py
index c1ea1249..003883e1 100644
--- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py
+++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py
@@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
assert extract_street_name(address) == expected
+@pytest.mark.parametrize(
+ "address,expected",
+ [
+ # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a
+ # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading
+ # postal index + admin parts ("620105, Свердловская обл, г
+ # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required
+ # the dot → keyword never matched on ANY DaData address → street-deals
+ # / sales-vs-listings endpoints silently returned empty for every
+ # DaData-geocoded request, not just this one.
+ (
+ "620105, Свердловская обл, г Екатеринбург, Академический р-н, "
+ "ул Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Same address, WITH the dot — must give the identical result (dot
+ # optional, not dot-forbidden).
+ (
+ "620105, Свердловская обл, г Екатеринбург, Академический р-н, "
+ "ул. Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Same address, full word "улица" — the alternation-order/backtracking
+ # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave
+ # "ица ..." behind.
+ (
+ "620105, Свердловская обл, г Екатеринбург, Академический р-н, "
+ "улица Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Without the leading postal index — same admin prefix otherwise.
+ (
+ "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5",
+ "Академика Парина",
+ ),
+ # Bare street+house, no admin prefix at all.
+ ("ул Академика Парина, д 46/5", "Академика Парина"),
+ # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр).
+ ("г Екатеринбург, пр Ленина, 5", "Ленина"),
+ ("г Екатеринбург, пер Красный, 4", "Красный"),
+ ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"),
+ # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression).
+ ("Екатеринбург, ул. Малышева, 1", "Малышева"),
+ ("Екатеринбург, ул Малышева, 1", "Малышева"),
+ ("Екатеринбург, улица Малышева, 1", "Малышева"),
+ ],
+)
+def test_extract_street_name_dadata_no_dot_abbreviations(
+ address: str | None, expected: str | None
+) -> None:
+ """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses
+ use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр")
+ — _STREET_KW_RE must match them exactly like the dotted forms."""
+ from app.services.estimator import extract_street_name
+
+ assert extract_street_name(address) == expected
+
+
# ── Helpers ───────────────────────────────────────────────────────────────────
diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py
new file mode 100644
index 00000000..767e6b87
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_version_api.py
@@ -0,0 +1,99 @@
+"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py +
+app/api/v1/version.py.
+
+Isolated FastAPI app (no full app.main import, no DB) — same pattern as
+tests/test_geocode_reverse_api.py: mount only the router under test.
+"""
+
+from __future__ import annotations
+
+import importlib
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.api.v1 import version as version_module
+from app.core import version as version_core
+
+
+@pytest.fixture
+def app() -> FastAPI:
+ application = FastAPI()
+ application.include_router(version_module.router, prefix="/api/v1/trade-in")
+ return application
+
+
+# ── GET /api/v1/trade-in/version ─────────────────────────────────────────────
+
+
+def test_version_endpoint_shape(app: FastAPI) -> None:
+ client = TestClient(app)
+ r = client.get("/api/v1/trade-in/version")
+ assert r.status_code == 200
+ body = r.json()
+ assert set(body.keys()) == {"version", "sha", "built_at"}
+ assert isinstance(body["version"], str) and body["version"]
+ assert isinstance(body["sha"], str) and body["sha"]
+ assert isinstance(body["built_at"], str) and body["built_at"]
+
+
+def test_version_endpoint_matches_core_constants(app: FastAPI) -> None:
+ client = TestClient(app)
+ body = client.get("/api/v1/trade-in/version").json()
+ assert body["version"] == version_core.APP_VERSION
+ assert body["sha"] == version_core.BUILD_SHA
+ assert body["built_at"] == version_core.BUILD_DATE
+
+
+def test_version_path_is_public_no_auth_required() -> None:
+ """rbac_guard must let this path through without X-Authenticated-User /
+ session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call."""
+ from app.core.rbac import _PUBLIC_PATHS
+
+ assert "/api/v1/trade-in/version" in _PUBLIC_PATHS
+
+
+# ── app/core/version.py — product_version_line / format_build_date_human ────
+
+
+def test_product_version_line_format() -> None:
+ line = version_core.product_version_line("Мера")
+ assert line.startswith("Мера v")
+ parts = line.split(" · ")
+ assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}"
+
+
+def test_format_build_date_human_parses_iso_utc() -> None:
+ assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026"
+
+
+def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None:
+ assert version_core.format_build_date_human("not-a-date") == "not-a-date"
+
+
+# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ──────
+# (local `uvicorn` run without a Docker build — see module docstring in
+# app/core/version.py). Reloading the module re-executes its module-level
+# env reads; nothing here may raise.
+
+
+def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("APP_VERSION", raising=False)
+ monkeypatch.delenv("BUILD_SHA", raising=False)
+ monkeypatch.delenv("BUILD_DATE", raising=False)
+
+ reloaded = importlib.reload(version_core)
+
+ assert reloaded.BUILD_SHA == "dev"
+ assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default
+ assert reloaded.BUILD_DATE.endswith("Z")
+ # format/product helpers must still work off the fallback values (no crash).
+ assert reloaded.product_version_line("Мера").startswith("Мера v")
+
+ # Reload once more so any test running later in this process sees a module
+ # state consistent with whatever env pytest was actually invoked under.
+ importlib.reload(version_core)
diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile
index 9abbd35a..5def62b7 100644
--- a/tradein-mvp/frontend/Dockerfile
+++ b/tradein-mvp/frontend/Dockerfile
@@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ENABLE_PREVIEW=""
ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW
+# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть
+# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические
+# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не
+# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник
+# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml,
+# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для
+# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже
+# frontend-код, потребляющий эти env (Dockerfile сам файл не читает).
+ARG NEXT_PUBLIC_APP_VERSION=""
+ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
+ARG NEXT_PUBLIC_BUILD_SHA=""
+ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA
+ARG NEXT_PUBLIC_BUILD_DATE=""
+ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE
+
+# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на
+# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI
+# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно
+# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job
+# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с
+# остальным контекстом. Локальная сборка без этого шага CI просто не находит
+# файл — readChangelog() уже умеет деградировать (пустая история), сам
+# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже).
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
@@ -49,6 +72,13 @@ ENV NODE_ENV=production \
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
+# /versions — Server Component, statically prerendered at `npm run build`
+# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked
+# into .next/standalone above. This is a defensive fallback ONLY, in case that
+# page ever stops being static: glob (trailing `*`) makes it a no-op when the
+# builder stage doesn't have the file either (local build without the CI
+# pre-copy step, see builder stage comment above) — never fails the build.
+COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./
USER node
EXPOSE 3000
diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx
index bf603195..c7d0543a 100644
--- a/tradein-mvp/frontend/src/app/v2/layout.tsx
+++ b/tradein-mvp/frontend/src/app/v2/layout.tsx
@@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
import { pageBg } from "@/components/trade-in/v2/tokens";
+import { VersionFooter } from "@/components/trade-in/VersionFooter";
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
// (no package.json change). Cyrillic + latin so RU labels render correctly.
@@ -53,6 +54,15 @@ export default function TradeInV2Layout({
products without the МЕРА brand that don't need a support link. */}
+ {/* Real build-version indicator (task: показать реальную версию
+ продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider —
+ it needs no chat context — but still scoped to this /v2 layout for
+ the same reason SupportButton is: other basePath routes
+ (/scrapers/**, /sale-share) are unrelated products without the
+ МЕРА brand. Portals to document.body itself (see VersionFooter.tsx
+ docstring), so its position in this tree only matters for mount
+ order, not DOM placement. */}
+