Merge branch 'main' into fix/3312-defer-interval-minutes
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m34s

This commit is contained in:
bot-backend 2026-09-05 18:15:47 +00:00
commit 7322615868
26 changed files with 1371 additions and 44 deletions

View file

@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════════════════════
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
#
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
# когда апстрим не отвечает.
#
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
# оставляет окно 3090 с, в котором контейнера просто нет: Caddy набирает
# новый апстрим сразу, тот ещё не слушает (замер по access-логам, #3274 —
# все 502 кластеризуются на окнах мержа, duration < 2 мс = мгновенный отказ
# соединения). Снипет НЕ УБИРАЕТ окно — он меняет то, что видит человек и
# клиент внутри окна. Настоящее лечение (готовность нового контейнера до
# переключения) — п.1 issue, решение владельца, здесь его нет.
#
# ПОЧЕМУ 503, А НЕ 502. 502 значит «апстрим ответил мусором» — постоянная
# поломка; поисковик по нему выкидывает страницу из индекса, клиентские
# библиотеки не ретраят. 503 + `Retry-After: 30` — стандартный код «временно
# недоступен, приходи через 30 секунд»: Googlebot держит страницу в индексе,
# HTTP-клиенты понимают, что повтор осмыслен.
#
# ПОЧЕМУ ДВА ТЕЛА. `/trade-in/api/*` вызывают из JS и внешних клиентов — они
# парсят JSON, и HTML-страница у них превращается в ошибку разбора вместо
# читаемого статуса. Всё остальное открывает человек браузером.
#
# ВНЕШНИХ РЕСУРСОВ В СТРАНИЦЕ НЕТ ВООБЩЕ — ни шрифта, ни CSS-файла, ни
# картинки. В окне деплоя они пришли бы с того же мёртвого апстрима, и
# страница-заглушка отрисовалась бы голым текстом. Отсюда же инлайновые
# `style=` вместо блока `<style>`: фигурные скобки в теле `respond` Caddy
# пытается разобрать как плейсхолдеры.
# ═══════════════════════════════════════════════════════════════════════════
@deployWindowApi path /trade-in/api/*
handle @deployWindowApi {
header Content-Type "application/json; charset=utf-8"
header Retry-After "30"
respond `{"detail":"Сервис обновляется, повторите запрос через минуту","error":"service_unavailable","retry_after":30}` 503
}
handle {
header Content-Type "text/html; charset=utf-8"
header Retry-After "30"
respond `<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Сервис обновляется</title>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#fff;color:#111">
<main style="max-width:30rem;padding:2rem;text-align:center">
<h1 style="font-size:1.25rem;font-weight:600;margin:0 0 .75rem">Сервис обновляется</h1>
<p style="margin:0;line-height:1.6;color:#444">Это занимает около минуты. Обновите страницу чуть позже — введённые данные не потеряются.</p>
</main>
` 503
}

View file

@ -267,6 +267,32 @@ gendsgn.ru {
}
}
}
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
# оставляет 3090 с, в которые контейнера нет и посетитель видит голый 502
# (замер по access-логам: 23 × 502 на этом домене за 4 суток, все —
# на окнах деплоя). Снипет подменяет это на 503 + Retry-After и читаемое
# тело; разбор «что лечится, а что нет» — в самом снипете.
#
# ГЕЙТ ПО ПУТИ ОБЯЗАТЕЛЕН: `handle_errors` объявляется на весь site-блок,
# а этот блок обслуживает ещё и Site Finder («Птица») — его апстримы
# (backend, frontend) деплоятся отдельным пайплайном и в задачу не входят.
# Пути вне матчера не попадают ни в один вложенный handle, ошибка остаётся
# необработанной, и Caddy отдаёт ровно то, что отдавал раньше. Проверено на
# живом Caddy 2.11.3 (dead-upstream 127.0.0.1:9): `/` и `/api/v1/*` —
# прежний пустой 502, `/trade-in/*` — новый 503.
#
# Матчер здесь сверяется с ИСХОДНЫМ путём запроса, а не с переписанным:
# для error-маршрута Caddy восстанавливает запрос, каким он пришёл. Поэтому
# `/trade-in/api/*` внутри снипета матчится, хотя на основном маршруте до
# падения апстрима уже отработал `uri strip_prefix /trade-in`. Тоже
# проверено на стенде, а не выведено из документации.
handle_errors 502 503 504 {
@tradeinScope path /trade-in /trade-in/*
handle @tradeinScope {
import ../deploy-window.caddy.snippet
}
}
}
www.gendsgn.ru {
@ -571,6 +597,19 @@ meraocenka.ru {
handle {
respond 404
}
# Окно деплоя (#3274). В отличие от gendsgn.ru гейт по пути не нужен: все
# апстримы этого блока — контейнеры МЕРЫ (tradein-frontend/tradein-backend),
# чей деплой и создаёт окно. 15 × 502 за 4 суток, все на окнах мержа.
#
# Белый список выше это НЕ ослабляет. Во-первых, `handle_errors`
# срабатывает только на перечисленные статусы, а отказ белого списка —
# 404. Во-вторых, `respond 404` пишет ответ напрямую и ошибкой маршрута
# вообще не является. Проверено на стенде: при мёртвом апстриме `/admin`
# по-прежнему отдаёт 404, а не страницу обновления.
handle_errors 502 503 504 {
import ../deploy-window.caddy.snippet
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.

View file

@ -778,6 +778,9 @@ services:
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
- ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro
# То же самое для страницы окна деплоя (#3274): caddy/sites/apps.caddy
# импортирует её как `import ../deploy-window.caddy.snippet`.
- ./caddy/deploy-window.caddy.snippet:/etc/caddy/caddy/deploy-window.caddy.snippet:ro
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
# держится в git через caddy/local/.gitignore — иначе docker создал бы
# отсутствующий bind-source сам, root-owned пустышкой.

View file

@ -52,15 +52,22 @@ check() {
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
elif [ -n "$reject" ] && printf '%s' "$head_and_body" | grep -qEi "$reject"; then
echo "FAIL: $desc ($url -> $code, но ответ от заглушки окна деплоя, а не от приложения)"
fail=1
else
echo "PASS: $desc ($url -> $code)"
fi
}
@ -251,8 +258,16 @@ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксиру
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
#
# С #3274 голый код 503 больше не доказывает ничего: Caddy сам отдаёт 503 на
# окне деплоя (`handle_errors` -> caddy/deploy-window.caddy.snippet), и тогда
# запрос до приложения не дошёл вовсе. Отличаем по признакам заглушки —
# заголовок `Retry-After: 30` и `"error":"service_unavailable"` в теле; у
# приложения тело `{"detail":"payments are disabled"}` и никакого Retry-After.
# Совпал код, но пришла заглушка → FAIL, а не молчаливый PASS.
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503 \
'service_unavailable|^retry-after: *30'
check "trade-in payments/notify — 405 на GET (только POST)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \

View file

@ -13,9 +13,11 @@ _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации сам URL (как и Slack-вебхуки).
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
нечем (GlitchTip-сторона его не добавляет).
2) единственный канал для аутентификации от САМОГО GlitchTip сам URL (как и
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
(предпочтительно не течёт в access-log, #3154), и из query-параметра
``?secret=`` как fallback для текущего отправителя.
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета тот же
@ -47,7 +49,7 @@ import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, ValidationError
from app.core.config import settings
@ -175,8 +177,9 @@ def _alerts_configured() -> bool:
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
# constant-time: длина/префикс секрета не утекают через время ответа.
if not secrets.compare_digest(provided or "", expected):
logger.warning("glitchtip webhook: invalid or missing secret query param")
logger.warning("glitchtip webhook: invalid or missing secret")
raise HTTPException(status_code=401, detail="invalid or missing secret")
@ -184,18 +187,27 @@ def _verify_secret(provided: str) -> None:
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) этот
хендлер сам делает единственную проверку (``secret`` query-параметр).
хендлер сам делает единственную проверку секрета.
Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр
``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому,
что query едет в access-log и оттуда в Loki открытым текстом; query оставлен,
т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()``
``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать
query можно только когда заголовок начнёт подставлять кто-то перед нами
(Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя.
"""
if not _alerts_configured():
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
_verify_secret(secret)
_verify_secret(header_secret or secret)
raw_body = await request.body()
received_at = datetime.now(UTC)

View file

@ -776,7 +776,16 @@ def load_estimate(
recommended_price=int(imv_raw["recommended_price"]),
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
# #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market
# считаем тем же порогом, что POST-путь в estimator, иначе одна и та же
# оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка.
market_count=(
int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None
),
thin_market=(
imv_raw.get("market_count") is not None
and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold
),
)
if imv_raw is not None and imv_raw.get("recommended_price")
else None

View file

@ -0,0 +1,72 @@
"""Секреты из query-строки не попадают в лог процесса (#3154).
Прод-факт: uvicorn пишет в access-log ПОЛНЫЙ путь вместе с query, а лог уезжает
в Loki (ретенция 30 суток, доступ по входу в Grafana):
INFO: 172.18.0.3:60322 - "POST /api/v1/trade-in/ops/glitchtip-webhook
?secret=<64 hex> HTTP/1.1" 200 OK
Скруббер в Alloy (#3115) это не ловит: там одно выражение под форму
``scheme://user:pass@host`` (DSN postgres_exporter, #3114). Чиним в СВОЁМ
процессе тогда секрета нет и в `docker logs`, до отправки куда-либо.
Фильтр вешается на логгер (`logging.Filter`), а не на форматтер: uvicorn.access
кладёт путь в ``record.args``, до форматирования он уже там. Поэтому берём
``record.getMessage()`` и, если что-то замаскировали, подменяем msg/args.
"""
from __future__ import annotations
import logging
import re
# Имена параметров, значение которых маскируем: имя ОКАНЧИВАЕТСЯ на чувствительное
# слово, поэтому перед альтернацией допускаем префикс (`client_secret`,
# `refresh_token`, `webhook_secret`). Значение — до следующего `&`, пробела или
# кавычки (access-строка uvicorn обрамляет запрос кавычками).
_SENSITIVE_QUERY = re.compile(
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
r"[^&\s\"'<>]+",
re.IGNORECASE,
)
def scrub_query_secrets(text: str) -> str:
"""Заменяет значения чувствительных query-параметров на ``***``.
Имя параметра и остальная строка сохраняются иначе access-лог перестал бы
годиться для диагностики.
"""
return _SENSITIVE_QUERY.sub(r"\1***", text)
class QuerySecretFilter(logging.Filter):
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
scrubbed = scrub_query_secrets(message)
if scrubbed != message:
record.msg = scrubbed
record.args = ()
return True
def install_query_secret_filter(*logger_names: str) -> None:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
Двумя местами, потому что uvicorn в своём log-config ставит `uvicorn.access`
собственный handler с ``propagate = False`` до корневого его записи не
доходят. А фильтр на handler'ах корня закрывает всё остальное приложение
(записи дочерних логгеров фильтры родителя не проходят, фильтры handler'а
проходят).
Идемпотентно: повторный вызов не наплодит дублей.
"""
targets: list[logging.Logger | logging.Handler] = [
logging.getLogger(name) for name in logger_names or ("uvicorn.access",)
]
targets.extend(logging.getLogger().handlers)
for target in targets:
if not any(isinstance(f, QuerySecretFilter) for f in target.filters):
target.addFilter(QuerySecretFilter())

View file

@ -44,6 +44,7 @@ from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.http_errors import install_validation_error_handler
from app.core.log_scrub import install_query_secret_filter
from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
@ -64,6 +65,11 @@ logging.basicConfig(
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
# Маскируем значения чувствительных query-параметров ДО записи строки.
install_query_secret_filter()
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),

View file

@ -1320,6 +1320,8 @@ def _apply_imv_blend(
anchor_higher: int | None,
weight: float,
threshold: float,
market_count: int | None = None,
thin_market_threshold: int = 0,
) -> tuple[int, int, float, bool, int | None]:
"""Чистая (testable без БД) blend-трансформация для #651.
@ -1330,12 +1332,22 @@ def _apply_imv_blend(
Если A ниже медианы медиану НЕ трогаем, но диапазон можем расширить, чтобы
включить A (информативность). Null-guard: при anchor_total=None no-op.
#3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь
статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend
медианы, ни расширение range_high. Гейт стоит здесь в единственной точке,
через которую IMV влияет на деньги, а не в ветках построения якоря.
`thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен
поведение прежнее.
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
anchor_used_total).
"""
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
return median_price, range_high, median_ppm2, False, None
if market_count is not None and market_count < thin_market_threshold:
return median_price, range_high, median_ppm2, False, None
blended = False
new_median = median_price
new_ppm2 = median_ppm2
@ -3351,7 +3363,12 @@ def _price_from_inputs(
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
_imv_mc = (
int(imv_anchor["market_count"])
if imv_anchor.get("market_count") is not None
else None
)
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(
@ -3397,17 +3414,27 @@ def _price_from_inputs(
),
)
# #audit-5b: thin-market warning.
# #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning,
# а IMV всё равно двигал headline и растягивал range_high — теперь гейт в
# `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это.
if avito_imv_summary is not None and avito_imv_summary.thin_market:
logger.warning(
"avito_imv thin_market #audit-5b: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV reliability low",
"avito_imv thin_market #3323: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного"
" пути (ни blend медианы, ни расширение range_high); остаётся"
" display-only в карточке avito_imv",
avito_imv_summary.market_count,
settings.avito_imv_thin_market_threshold,
)
if anchor_total is not None:
imv_anchor_present = True
# #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b
# (#764, quarter-index) не должен глушить поправку «потому что якорь есть» —
# иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет
# деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками.
imv_anchor_present = (
not avito_imv_summary.thin_market if avito_imv_summary is not None else True
)
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
@ -3417,6 +3444,10 @@ def _price_from_inputs(
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
threshold=settings.estimate_imv_blend_threshold,
market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None
),
thin_market_threshold=settings.avito_imv_thin_market_threshold,
)
if blended:
logger.info(
@ -3448,7 +3479,11 @@ def _price_from_inputs(
# median/expected_sold/ranges блок не трогает.
if avito_imv_summary is None:
if imv_anchor is not None and imv_anchor.get("recommended_price"):
_disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
_disp_mc = (
int(imv_anchor["market_count"])
if imv_anchor.get("market_count") is not None
else None
)
avito_imv_summary = AvitoImvSummary(
recommended_price=int(imv_anchor["recommended_price"]),
lower_price=(

View file

@ -684,6 +684,28 @@ async def run_avito_detail_backfill(
)
if save_detail_enrichment(db, enrichment):
counters.enriched += 1
else:
# #3338 (та же дыра, что #3332 у domclick): карточка взята и
# разобрана, а UPDATE не задел ни одной строки — объявление
# удалено/деактивировано между снимком и записью. Попытка была,
# исхода не было: attempted переставал сходиться с
# enriched + blocked + gone + failed, и расхождение читается как
# потерянный отказ площадки. Исход failed: непрошедший UPDATE — не
# успех, не блок и не gone (снятие метит is_active=FALSE сам, по 404).
counters.failed += 1
# Печатаем ОБА идентификатора: WHERE в save ключуется по
# source_id из разобранного HTML (item_id), а не по row-id из
# снимка. При редиректе/подмене карточки строка listing_id
# существует и жива — не нашлась строка с source_id=item_id.
logger.warning(
"avito_detail_backfill: run_id=%d listing %s -- карточка "
"разобрана, но UPDATE не нашёл строку: listing_id=%s item_id=%s "
"(WHERE по item_id из HTML)",
run_id,
source_url,
row["id"],
enrichment.item_id,
)
if use_curl:
items_since_warm += 1
breaker.record_success()

View file

@ -458,6 +458,21 @@ async def run_yandex_detail_backfill(
consecutive_blocks = 0
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
else:
# #3338 (та же дыра, что #3332 у domclick): страница взята и
# разобрана, а UPDATE не задел ни одной строки — объявление
# удалено/деактивировано между снимком и записью. Попытка была,
# исхода не было: attempted переставал сходиться с enriched +
# failed, и расхождение читается как потерянный отказ площадки.
# Исход failed: непрошедший UPDATE — не успех и не блок.
counters.failed += 1
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
"-- карточка разобрана, но UPDATE не задел ни одной строки",
run_id,
listing_id,
source_url,
)
except Exception as exc:
counters.failed += 1

View file

@ -0,0 +1,135 @@
"""Секрет из query-строки не попадает в лог процесса (#3154).
Прод-факт: uvicorn access-log печатал полный путь вместе с `?secret=<64 hex>`
(секрет вебхука GlitchTip = `TRADEIN_INTERNAL_AUTH_SECRET`), лог уезжал в Loki и
лежал там открытым. Скруббер Alloy (#3115) ловит только форму `user:pass@host`.
Проверка ПО ЗНАЧЕНИЮ: строка гоняется через настоящий handler с фильтром, в
выводе должно быть `secret=***` и НЕ должно быть самого секрета.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import io
import logging
import pytest
from app.core.log_scrub import QuerySecretFilter, install_query_secret_filter, scrub_query_secrets
_SECRET = "5fc281ce5d1bcf612417dffcae51d82fe5c3db2d7c4b4eac9a1b2c3d4e5f60718"
# Настоящая форма access-строки uvicorn (msg + args, путь лежит в args).
_ACCESS_MSG = '%s - "%s %s HTTP/%s" %d'
def _emit(logger_name: str, msg: str, *args: object) -> str:
"""Прогоняет запись через настоящий handler и возвращает текст строки."""
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
logger = logging.getLogger(logger_name)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
previous_propagate = logger.propagate
logger.propagate = False
try:
logger.info(msg, *args)
finally:
logger.propagate = previous_propagate
logger.removeHandler(handler)
return stream.getvalue()
def test_uvicorn_access_line_masks_secret_query_param() -> None:
install_query_secret_filter("uvicorn.access")
line = _emit(
"uvicorn.access",
_ACCESS_MSG,
"172.18.0.3:60322",
"POST",
f"/api/v1/trade-in/ops/glitchtip-webhook?secret={_SECRET}",
"1.1",
200,
)
assert "secret=***" in line, line
assert _SECRET not in line, line
# Остальная строка цела — иначе access-лог перестал бы годиться для разбора.
assert "POST /api/v1/trade-in/ops/glitchtip-webhook?secret=*** HTTP/1.1" in line
assert "172.18.0.3:60322" in line and "200" in line
def test_app_logger_via_root_handler_masks_secret() -> None:
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты."""
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
handler.addFilter(QuerySecretFilter())
root = logging.getLogger()
root.addHandler(handler)
try:
logging.getLogger("app.some.module").warning(
"retry callback https://gendsgn.ru/hook?token=%s", _SECRET
)
finally:
root.removeHandler(handler)
out = stream.getvalue()
assert "token=***" in out, out
assert _SECRET not in out, out
@pytest.mark.parametrize(
"raw",
[
f"/hook?secret={_SECRET}",
f"/hook?SECRET={_SECRET}",
f"/hook?a=1&token={_SECRET}&b=2",
f"/hook?api_key={_SECRET}",
f"/hook?apiKey={_SECRET}",
f'"GET /hook?access_token={_SECRET} HTTP/1.1"',
# Имя с префиксом: чувствительное слово в КОНЦЕ имени параметра.
f"/hook?client_secret={_SECRET}",
f"/hook?webhook_secret={_SECRET}",
f"/hook?refresh_token={_SECRET}",
f"/hook?auth_token={_SECRET}",
],
)
def test_sensitive_param_names_are_masked(raw: str) -> None:
scrubbed = scrub_query_secrets(raw)
assert _SECRET not in scrubbed, scrubbed
assert "***" in scrubbed
@pytest.mark.parametrize(
"raw",
[
"GET /api/v1/trade-in/offers?limit=50&city=Екатеринбург",
"https://metrics.gendsgn.ru/ingest/loki/api/v1/push",
# Имя НЕ оканчивается на чувствительное слово — маскировать нечего.
"/hook?secretary=anna",
"/hook?tokens_page=2",
# `token-info` в ПУТИ, а не в query: значения там нет вовсе.
"GET /api/v1/token-info?limit=5",
],
)
def test_innocent_lines_untouched(raw: str) -> None:
assert scrub_query_secrets(raw) == raw
def test_filter_installed_by_app_main() -> None:
"""Проводка: импорт приложения ставит фильтр на access-лог uvicorn."""
import app.main # noqa: F401 (импорт ради побочного эффекта установки фильтра)
filters = logging.getLogger("uvicorn.access").filters
assert any(isinstance(f, QuerySecretFilter) for f in filters), filters
def test_install_is_idempotent() -> None:
install_query_secret_filter("uvicorn.access")
install_query_secret_filter("uvicorn.access")
filters = logging.getLogger("uvicorn.access").filters
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters

View file

@ -31,6 +31,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import httpx # noqa: E402
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
@ -92,6 +93,14 @@ def _empty_pool_block() -> DomClickBlockedError:
return blocked
def _transport_block() -> DomClickBlockedError:
"""Сбой НАШЕЙ стороны (таймаут/5xx сайдкара): httpx-ошибка в __cause__ —
ровно то, что читает `_is_transport_failure` (#3283)."""
blocked = DomClickBlockedError("browser fetch failed")
blocked.__cause__ = httpx.ConnectTimeout("sidecar timed out")
return blocked
async def _run(
fetch: AsyncMock, *, snapshot: int, save_ok: bool = True
) -> tuple[DomClickDetailBackfillResult, MagicMock]:
@ -160,6 +169,25 @@ async def test_missing_row_on_save_keeps_identity() -> None:
)
@pytest.mark.asyncio
async def test_transport_failure_then_empty_pool_counts_each_once() -> None:
"""Контроль двойного начисления на самом пути пула (#3338, просьба ревью).
Транспортный сбой уже начисляет failed и идёт `continue`; следующая попытка
упирается в пустой пул и начисляет failed повторно но СВОЙ, за СВОЮ
попытку. attempted=2 и failed=2, а не 3: правка #3332 не должна начислять
исход второй раз за ту же попытку.
"""
fetch = AsyncMock(side_effect=[_transport_block(), _empty_pool_block()])
counters, _ = await _run(fetch, snapshot=5)
_assert_identity(counters, expected_attempted=2)
assert (counters.failed, counters.blocked, counters.enriched) == (2, 0, 0), (
f"failed={counters.failed} blocked={counters.blocked} enriched={counters.enriched}, "
"ожидали 2/0/0 — по одному отказу нашей стороны на каждую из двух попыток"
)
@pytest.mark.asyncio
async def test_blocked_and_enriched_counted_once() -> None:
"""Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""

View file

@ -0,0 +1,212 @@
"""SIGTERM-дрейн помечен `interrupted=1` во ВСЕХ свипах, не только у avito (#3333).
После #3319 резюм подхватывает 'done'-прогоны только с меткой `interrupted`
(`_drained_done` в scheduler._resume_decision), а ставил её ровно один
avito_city_sweep. У yandex (~2356 стр.), cian (~2960) и newbuilding (~2098)
дрейн финализировался чистым `done` с частичными счётчиками: недоделанный обход
объявлен полным, статус тот же, что у честного, и из резюма он выпадал.
Резюм есть у всех трёх (чекпоинт `done_buckets`: combo-метки у yandex, имена
якорей у cian, номера страниц у newbuilding), так что метка не диагностическая:
у каждого есть что подхватывать. Оговорка одна yandex подхватывает только при
единственном якоре (combo-ключ не содержит якоря); прод-режим ровно такой.
Анти-цикл (последний тест): метка не должна превратить ЛЮБОЙ 'done' в
резюмируемый иначе источник больше никогда не обходится целиком.
"""
from __future__ import annotations
import os
# Settings собирается автофикстурой conftest'а и требует database_url — как в
# test_3319_citysweep_checkpoint.py, до остальных импортов.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
ANCHOR_A = (56.83, 60.60, "ekb-center")
ANCHOR_B = (56.79, 60.63, "ekb-south")
class _FakeDb:
"""Все UPDATE'ы с counters (heartbeat И финализаторы) складываются по порядку."""
def __init__(self) -> None:
self.writes: list[dict[str, Any]] = []
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params and "counters" in params:
self.writes.append(json.loads(params["counters"]))
return MagicMock()
def commit(self) -> None: ...
def rollback(self) -> None: ...
class _FakeAsyncSession:
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
async def __aenter__(self) -> _FakeAsyncSession:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
class _NeverCalledScraper:
"""Дрейн срабатывает ДО скрапера: любой запрос тут — сломанный порядок проверок."""
def __init__(self, *_a: Any, **_kw: Any) -> None:
self._browser = None
self._cffi = None
self.state_extraction_attempts = 1
self.state_extraction_failures = 0
self.request_delay_sec = 0.0
async def __aenter__(self) -> _NeverCalledScraper:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
def __getattr__(self, name: str) -> Any:
async def _boom(*_a: Any, **_kw: Any) -> Any:
raise AssertionError(f"скрапер вызван при дрейне: {name}")
return _boom
def _config() -> types.SimpleNamespace:
return types.SimpleNamespace(
scraper_fetch_mode="cffi",
scraper_proxy_url=None,
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
avito_serp_ok_not_banned=True,
)
@pytest.mark.asyncio
async def test_yandex_drain_is_marked_interrupted() -> None:
"""yandex-sweep: дрейн на границе якоря → counters.interrupted == 1."""
from scraper_kit.orchestration import pipeline as pl
db = _FakeDb()
enrichment = MagicMock()
enrichment.record_yandex_price_history.return_value = 0
with (
patch.object(pl, "YandexRealtyScraper", _NeverCalledScraper),
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
await pl.run_yandex_city_sweep(
db, # type: ignore[arg-type]
run_id=3333,
config=_config(),
matcher=MagicMock(),
enrichment=enrichment,
enrich_address=False,
shutdown_requested=lambda: True,
)
assert db.writes, "дрейн не оставил ни одной записи counters"
assert db.writes[-1].get("interrupted") == 1, (
"yandex: оборванный дрейном прогон неотличим от полного обхода — резюм его не возьмёт"
)
@pytest.mark.asyncio
async def test_cian_drain_is_marked_interrupted() -> None:
"""cian-sweep: дрейн на границе якоря → counters.interrupted == 1."""
from scraper_kit.orchestration import pipeline as pl
db = _FakeDb()
with (
patch.object(pl, "CianScraper", _NeverCalledScraper),
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
await pl.run_cian_city_sweep(
db, # type: ignore[arg-type]
run_id=3333,
config=_config(),
matcher=MagicMock(),
anchors=[ANCHOR_A, ANCHOR_B],
enrich_houses=False,
detail_top_n=0,
request_delay_sec=0.0,
shutdown_requested=lambda: True,
)
assert db.writes, "дрейн не оставил ни одной записи counters"
assert db.writes[-1].get("interrupted") == 1, (
"cian: оборванный дрейном прогон неотличим от полного обхода — резюм его не возьмёт"
)
@pytest.mark.asyncio
async def test_newbuilding_drain_is_marked_interrupted() -> None:
"""nb-sweep: дрейн до SERP-фазы → counters.interrupted == 1."""
from scraper_kit.orchestration import pipeline as pl
db = _FakeDb()
with (
patch.object(pl, "AvitoScraper", _NeverCalledScraper),
patch.object(pl, "AsyncSession", _FakeAsyncSession),
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
await pl.run_avito_newbuilding_sweep(
db, # type: ignore[arg-type]
run_id=3333,
config=_config(),
matcher=MagicMock(),
pages=4,
request_delay_sec=0.0,
shutdown_requested=lambda: True,
)
assert db.writes, "дрейн не оставил ни одной записи counters"
assert db.writes[-1].get("interrupted") == 1, (
"newbuilding: оборванный дрейном прогон неотличим от полного обхода — резюм его не возьмёт"
)
def _prev_run(counters: dict[str, Any]) -> types.SimpleNamespace:
return types.SimpleNamespace(
prev_id=4707,
prev_status="done",
prev_counters=counters,
same_params=True,
age_h=2.0,
interval_days="7",
)
def test_clean_done_is_not_resumed_but_marked_drain_is() -> None:
"""Анти-цикл: подхватывается ТОЛЬКО помеченный дрейн, чистый 'done' — нет.
Общий на все три провайдера: `_resume_decision` смотрит на counters, а не на
источник, поэтому разбор один. Если бы метка была не нужна для подхвата, все
три правки выше были бы записью в лог ради записи в лог.
"""
from scraper_kit.orchestration.scheduler import _resume_decision
ckpt = {"done_buckets": ["combo-1"], "resume_chain": 0}
resume_from, verdict = _resume_decision(_prev_run({**ckpt, "interrupted": 1}))
assert resume_from == 4707, f"помеченный дрейн не подхвачен: {verdict}"
resume_from, verdict = _resume_decision(_prev_run(ckpt))
assert resume_from is None, "чистый 'done' — полный обход, подхватывать нечего"
assert verdict["resume_reason"] == "status_done"

View file

@ -0,0 +1,280 @@
"""Тождество счётчиков у соседей domclick: yandex/avito detail-backfill (#3338).
Тот же дефект, что #3332 (см. tests/test_3332_domclick_counter_identity.py):
`counters.attempted` инкрементируется ДО попытки, исход дописывается при разборе
результата а ветка `save_detail_enrichment(...) -> False` исхода не дописывала
вовсе (`if save(...): enriched += 1` без else). Страницу взяли, разобрали, а
UPDATE не задел ни одной строки (объявление удалено/деактивировано между
снимком и записью) попытка была, исхода не было. Расхождение
`attempted - сумма исходов` читается как потерянный отказ площадки.
Разбор ВСЕХ точек выхода из цикла попыток показал, что это единственная дыра у
обоих (у domclick второй был обрыв «пул прокси пуст» у соседей такого пути
нет: `resolve_proxy_url` бросает ProxyPoolExhaustedError ДО цикла):
yandex fetch-исключение failed; non-200 blocked+failed; parseNone
failed; saveFalse БЫЛА ДЫРА; общий except failed.
avito saveFalse БЫЛА ДЫРА; AvitoListingGoneError gone;
Blocked/RateLimited blocked; TimeoutError failed;
общий except failed. Обрывы по budget/SIGTERM стоят ДО
`attempted += 1`, они попытку не создают.
Формы тождества у файлов РАЗНЫЕ, и это не описка:
* yandex: `blocked` документирован как ПОДМНОЖЕСТВО `failed` (dataclass,
#3196) — non-200 инкрементирует оба, поэтому сумма исходов = enriched + failed;
* avito: `blocked`/`gone`/`failed` непересекающиеся корзины, сумма исходов =
enriched + blocked + gone + failed.
Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение». Вторым
кейсом на каждый файл идёт контроль на противоположную ошибку что правка не
начисляет исход дважды. Харнессы зеркалят tests/test_3196_yandex_ban_kind.py и
tests/test_3283g_rotate_on_platform_ban.py.
"""
from __future__ import annotations
import os
import sys
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.avito_exceptions import AvitoBlockedError # noqa: E402
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # noqa: E402
AvitoDetailBackfillResult,
run_avito_detail_backfill,
)
from app.tasks.yandex_detail_backfill import ( # noqa: E402
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
# ── yandex ────────────────────────────────────────────────────────────────────
_Y_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_Y_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_Y_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_Y_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_Y_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_Y_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
# ── avito ─────────────────────────────────────────────────────────────────────
_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
_A_SETTINGS = "app.tasks.avito_detail_backfill.settings"
_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy"
@pytest.fixture(autouse=True)
def _reset_shutdown() -> None:
_sd.reset_shutdown()
yield
_sd.reset_shutdown()
def _assert_identity(
attempted: int, outcomes: int, *, expected_attempted: int, detail: str
) -> None:
assert attempted == expected_attempted, (
f"attempted={attempted}, ожидали {expected_attempted} попыток"
)
assert attempted == outcomes, (
f"тождество нарушено: attempted={attempted}, сумма исходов={outcomes} ({detail}), "
f"потеряно {attempted - outcomes} попыток без исхода"
)
def _assert_yandex_identity(c: YandexDetailBackfillResult, *, expected_attempted: int) -> None:
# blocked ⊆ failed (см. докстринг модуля) — в сумму входит только failed.
_assert_identity(
c.attempted,
c.enriched + c.failed,
expected_attempted=expected_attempted,
detail=f"enriched={c.enriched} failed={c.failed} (blocked={c.blocked} ⊆ failed)",
)
def _assert_avito_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None:
_assert_identity(
c.attempted,
c.enriched + c.blocked + c.gone + c.failed,
expected_attempted=expected_attempted,
detail=(f"enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}"),
)
def _mock_yandex_db(n: int) -> MagicMock:
snapshot = [
{"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0)
db.execute.return_value = sel
return db
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>ok</html>"
return resp
def _yandex_session_cls(responses: list[MagicMock]) -> MagicMock:
session = AsyncMock()
session.get = AsyncMock(side_effect=responses)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
return MagicMock(return_value=ctx)
async def _run_yandex(
responses: list[MagicMock], *, save_ok: bool, parse_result: Any = None
) -> YandexDetailBackfillResult:
count = len(responses)
db = _mock_yandex_db(count)
with (
patch(_Y_ASYNC_SESSION, _yandex_session_cls(responses)),
patch(_Y_PARSE, return_value=parse_result or MagicMock()),
patch(_Y_SAVE, return_value=save_ok),
patch(_Y_RUNS, MagicMock()),
patch(_Y_SLEEP, new_callable=AsyncMock),
patch(_Y_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
return await run_yandex_detail_backfill(
db,
run_id=3338,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": 10},
)
@pytest.mark.asyncio
async def test_yandex_missing_row_on_save_keeps_identity() -> None:
"""save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
counters = await _run_yandex([_resp(200), _resp(200)], save_ok=False)
_assert_yandex_identity(counters, expected_attempted=2)
assert (counters.enriched, counters.failed, counters.blocked) == (0, 2, 0), (
f"enriched={counters.enriched} failed={counters.failed} blocked={counters.blocked}: "
"непрошедший UPDATE — не успех и не блок площадки"
)
@pytest.mark.asyncio
async def test_yandex_outcomes_counted_once() -> None:
"""Контроль на противоположную ошибку: успех и non-200 — по одному разу.
non-200 инкрементирует и blocked, и failed НАРОЧНО (blocked failed, #3196);
правка #3338 не должна добавлять там третий инкремент.
"""
counters = await _run_yandex([_resp(200), _resp(403)], save_ok=True)
_assert_yandex_identity(counters, expected_attempted=2)
assert (counters.enriched, counters.failed, counters.blocked) == (1, 1, 1), (
f"enriched={counters.enriched} failed={counters.failed} blocked={counters.blocked}, "
"ожидали 1/1/1 — исход начислен дважды"
)
def _fake_avito_settings() -> MagicMock:
return MagicMock(
scraper_fetch_mode="browser",
avito_detail_backfill_use_curl=False,
detail_backfill_block_ratio_window=20,
detail_backfill_block_ratio_threshold=0.7,
browser_http_endpoint="http://browser:9000",
avito_detail_backfill_rotate_after_attempts=15,
avito_detail_backfill_rotate_on_ban_max=0,
avito_detail_backfill_rotate_on_ban_min_gap=10,
)
def _mock_avito_db(n: int) -> MagicMock:
snapshot = [
{
"id": i + 1,
"source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}",
}
for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_avito_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.request_context_reset = MagicMock()
instance.lease_id = 42
return MagicMock(return_value=instance)
async def _run_avito(fetch_results: list[Any], *, save_ok: bool) -> AvitoDetailBackfillResult:
count = len(fetch_results)
db = _mock_avito_db(count)
with (
patch(_A_SETTINGS, _fake_avito_settings()),
patch(_A_SESSION),
patch(_A_SCRAPER),
patch(_A_RUNS, MagicMock()),
patch(_A_BROWSER_FETCHER, _mock_avito_browser_fetcher_cls()),
patch(_A_FETCH, AsyncMock(side_effect=fetch_results)),
patch(_A_SAVE, return_value=save_ok),
patch(_A_ROTATE_PROXY, AsyncMock()),
patch(_A_SLEEP, new_callable=AsyncMock),
):
return await run_avito_detail_backfill(
db,
run_id=3338,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": 10},
)
@pytest.mark.asyncio
async def test_avito_missing_row_on_save_keeps_identity() -> None:
"""save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
counters = await _run_avito([MagicMock(), MagicMock()], save_ok=False)
_assert_avito_identity(counters, expected_attempted=2)
assert (counters.enriched, counters.failed) == (0, 2), (
f"enriched={counters.enriched} failed={counters.failed}: непрошедший UPDATE — не успех"
)
assert (counters.blocked, counters.gone) == (0, 0), (
f"blocked={counters.blocked} gone={counters.gone}: площадка ответила и ничего "
"не снимала — исход отказа НАШЕЙ стороны, чужие корзины трогать нельзя"
)
@pytest.mark.asyncio
async def test_avito_outcomes_counted_once() -> None:
"""Контроль на противоположную ошибку: успех/блок/таймаут — по одному разу."""
counters = await _run_avito(
[MagicMock(), AvitoBlockedError("firewall/soft-block"), TimeoutError("fetch stalled")],
save_ok=True,
)
_assert_avito_identity(counters, expected_attempted=3)
assert (counters.enriched, counters.blocked, counters.failed, counters.gone) == (1, 1, 1, 0), (
f"enriched={counters.enriched} blocked={counters.blocked} "
f"failed={counters.failed} gone={counters.gone}, "
"ожидали 1/1/1/0 — правка #3338 не должна начислять исход дважды"
)

View file

@ -35,6 +35,7 @@ import asyncio
import logging
import os
import re
import threading
import time
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
@ -722,9 +723,21 @@ def test_failed_login_events_reach_audit_with_counter_state(
# #2665 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл
# ---------------------------------------------------------------------------
# Порог ТЕМПА пробы (тик/с) для #3343. Не «сколько раз», а «как часто»: `elapsed`
# под нагрузкой растёт, поэтому абсолютное «>= 10 тиков» заблокированный цикл
# наберёт за 3-5с — прежний ассерт слабел ровно в том сценарии, ради которого
# написан. Замеры (macOS, 2026-09-05, три прогона каждый):
# пул (`asyncio.to_thread`, как в проде) — 65.5 / 65.6 / 66.3 тик/с;
# bcrypt прямо в `async def` (`return verify_password(...)` в
# `verify_password_bounded`) — 1.0 тик/с (6 тиков за 5.74с; заметь: старый
# порог «>= 10» на чуть более медленной машине эти 10 тиков добрал бы).
# Порог — геометрическая середина: sqrt(65.5 * 1.0) ≈ 8, то есть запас ×8 в обе
# стороны. Общий раннер отъедает пропускную способность, но не порядок величины.
MIN_PROBE_TICKS_PER_S = 8.0
async def test_login_flood_capped_by_rate_while_api_stays_responsive(
store: _Store, monkeypatch: pytest.MonkeyPatch
store: _Store, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при
этом остальной API продолжает отвечать.
@ -763,14 +776,19 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
app = _build_test_app(store)
attempts: list[float] = []
verify_threads: set[int] = set()
def _slow_verify(plain: str, hashed: str) -> bool:
"""Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше.
Блокирующий `time.sleep`, а не `await` суть проблемы в том, что bcrypt
не отпускает поток; двойник с `await` проверял бы не то.
Заодно записывает ПОТОК исполнения: это и есть механизм выноса (#3343) —
величина дискретная, от загрузки раннера не зависящая.
"""
attempts.append(time.monotonic())
verify_threads.add(threading.get_ident())
time.sleep(verify_s)
return False
@ -818,25 +836,60 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
codes = [c for lst in code_lists for c in lst]
attempts_per_s = len(attempts) / elapsed
probe_latencies.sort()
probe_ticks_per_s = len(probe_latencies) / elapsed
median_probe = probe_latencies[len(probe_latencies) // 2] if probe_latencies else float("nan")
worst_probe = probe_latencies[-1] if probe_latencies else float("nan")
# Печать ДО первого утверждения и МИМО перехвата: захваченный вывод зелёного
# теста pytest не показывает (CI гоняет `uv run pytest -q -rs`), а запас на
# общем раннере интересен ровно когда всё прошло. Выше assert'ов — намеренно:
# на сломанной системе иначе не осталось бы и диагностики.
with capsys.disabled():
print(
f"[#3343] проба {len(probe_latencies)} тиков за {elapsed:.2f}с = "
f"{probe_ticks_per_s:.1f} тик/с (порог {MIN_PROBE_TICKS_PER_S} тик/с), медиана "
f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс; сверок "
f"{len(attempts)} = {attempts_per_s:.0f}/с при потолке {ceiling_per_s:.0f}/с"
)
# 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def`
# сторонний запрос ждёт столько, сколько длится очередь сверок.
# Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда
# остальные числа мерят не потолок, а паралич — их надо читать после
# этого вердикта, а не вместо него.
assert probe_latencies, "проба не сделала ни одного запроса"
probe_latencies.sort()
assert probe_latencies[-1] < 0.5, (
f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа"
#
# Вердикт — про МЕХАНИЗМ, а не про секундомер (#3343). Раньше здесь стоял
# абсолютный порог латентности пробы (`худший < 500мс`, `медиана < 50мс`).
# На общем раннере он мерил не свойство кода, а свободен ли CPU у соседей:
# 03.09 деплой встал на «худший сторонний запрос 544мс» ровно на том
# коммите, который часом раньше прошёл на незанятой машине. Порог, который
# краснеет от чужой параллельной сборки, не отличает поломку от нагрузки,
# а красный обязан значить «значение неверно», иначе его начинают
# переспрашивать. Обе замены переживают любую загрузку:
# - ПОТОК, в котором исполнилась сверка, — величина дискретная. Вернись
# bcrypt в `async def` — здесь окажется поток цикла, и никакой простой
# раннера этого не замаскирует и не подделает;
# - ТЕМП тиков пробы: цикл с bcrypt внутри стоит практически всю
# секунду флуда (сверки идут подряд по 50мс), и проба не успевает
# почти никогда; при выносе в пул она просыпается раз в 10мс.
# Сверяется именно ТЕМП (тик/с), а не число тиков: `elapsed` под
# нагрузкой растёт, и абсолютный порог «>= 10 тиков» заблокированный
# цикл наберёт за 3-5с — то есть абсолютный ассерт слабеет ровно в
# целевом сценарии. Порог MIN_PROBE_TICKS_PER_S — геометрическая
# середина между замерами, см. константу.
# Сами латентности остаются в сообщении как ДИАГНОСТИКА: числа полезны,
# когда тест красный, и не годятся в вердикт, пока машина общая.
assert verify_threads, "ни одной сверки пароля не состоялось — мерить нечего"
assert threading.get_ident() not in verify_threads, (
"сверка пароля исполнилась в потоке событийного цикла — bcrypt держит весь "
"API на всё время проверки (вынос в пул из #2665 отменён)"
)
median_probe = probe_latencies[len(probe_latencies) // 2]
assert median_probe < verify_s, (
f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной "
f"сверки — цикл занят проверкой пароля, API стоит"
)
# Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс.
assert len(probe_latencies) >= 10, (
f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
assert probe_latencies, "проба не сделала ни одного запроса — цикл был занят"
assert probe_ticks_per_s >= MIN_PROBE_TICKS_PER_S, (
f"проба тикала {probe_ticks_per_s:.1f} раз/с при пороге {MIN_PROBE_TICKS_PER_S} "
f"({len(probe_latencies)} тиков за {elapsed:.2f}с, медиана "
f"{median_probe * 1000:.0f}мс, худший {worst_probe * 1000:.0f}мс) — цикл был занят"
)
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает

View file

@ -55,6 +55,103 @@ def test_blend_premium_raises_median_and_extends_range() -> None:
assert anchor_used == 100_000_000
def test_blend_thin_market_gate_drops_imv_from_money_path_3323() -> None:
"""#3323: market_count=1 → IMV не двигает ни headline, ни range_high.
Те же числа, что в test_blend_premium_raises_median_and_extends_range
(там 50М 75М, range 60М 110М). С тонким рынком ждём ЧИСТЫЙ расчёт:
медиана 50М и range_high 60М без вклада IMV.
"""
area = 80.0
median_price = 50_000_000
range_high = 60_000_000
median_ppm2 = median_price / area
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
median_ppm2=median_ppm2,
area=area,
anchor_total=100_000_000,
anchor_higher=110_000_000,
weight=0.5,
threshold=1.15,
market_count=1,
thin_market_threshold=10,
)
assert blended is False
assert new_median == 50_000_000
assert new_range_high == 60_000_000
assert new_ppm2 == median_ppm2
assert anchor_used is None
def test_blend_thick_market_unchanged_regression_3323() -> None:
"""#3323 регрессия: market_count >= threshold → числа как до гейта."""
area = 80.0
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=50_000_000,
range_high=60_000_000,
median_ppm2=50_000_000 / area,
area=area,
anchor_total=100_000_000,
anchor_higher=110_000_000,
weight=0.5,
threshold=1.15,
market_count=10,
thin_market_threshold=10,
)
assert blended is True
assert new_median == 75_000_000
assert new_range_high == 110_000_000
assert new_ppm2 == 75_000_000 / area
assert anchor_used == 100_000_000
def test_blend_market_count_none_passes_gate_3323() -> None:
"""#3323 контракт: market_count неизвестен (None) → гейт не срабатывает."""
new_median, _, _, blended, _ = _apply_imv_blend(
median_price=50_000_000,
range_high=60_000_000,
median_ppm2=625_000.0,
area=80.0,
anchor_total=100_000_000,
anchor_higher=110_000_000,
weight=0.5,
threshold=1.15,
market_count=None,
thin_market_threshold=10,
)
assert blended is True
assert new_median == 75_000_000
def test_blend_thin_market_gate_boundary_3323() -> None:
"""#3323 граница: threshold-1 → гейт закрыт, threshold → открыт."""
area = 80.0
kwargs: dict[str, Any] = {
"median_price": 50_000_000,
"range_high": 60_000_000,
"median_ppm2": 50_000_000 / area,
"area": area,
"anchor_total": 100_000_000,
"anchor_higher": 110_000_000,
"weight": 0.5,
"threshold": 1.15,
"thin_market_threshold": 10,
}
gated = _apply_imv_blend(**kwargs, market_count=9)
assert gated[:2] == (50_000_000, 60_000_000)
assert gated[3] is False
passed = _apply_imv_blend(**kwargs, market_count=10)
assert passed[:2] == (75_000_000, 110_000_000)
assert passed[3] is True
def test_blend_no_op_when_anchor_below_median() -> None:
"""A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться."""
area = 50.0

View file

@ -388,6 +388,7 @@ def _run_estimate_qi(
*,
anchor_tier_override: str | None = None,
analog_indexes: dict[str, float] | None = None,
imv_anchor: dict[str, Any] | None = None,
):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
@ -446,7 +447,7 @@ def _run_estimate_qi(
"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._fetch_house_imv_anchor", return_value=imv_anchor),
# Stub singular target-quarter lookup
patch(
"app.services.estimator._lookup_quarter_index",
@ -881,6 +882,59 @@ def test_guard1b_imv_blend_prevents_correction() -> None:
assert "квартал" not in (est.confidence_explanation or "").lower()
def test_guard1b_thin_market_imv_does_not_block_quarter_index_3323() -> None:
"""#3323: тонкий рынок → якорь отброшен, значит Guard-1b НЕ глушит поправку.
Тот же вход, что в test_guard1b_imv_blend_prevents_correction (anchor 30М
медианы 6М), но market_count=1 < порога 10. Ждём по значению: blend не
сработал (не 18М) и квартальная поправка ПРИМЕНИЛАСЬ 6М × 1.2.
"""
base_median = round(_BASE_PPM2 * _AREA) # 6_000_000
thin_anchor = {
"recommended_price": 30_000_000,
"lower_price": 25_000_000,
"higher_price": 35_000_000,
"market_count": 1, # < settings.avito_imv_thin_market_threshold (10)
"rooms": 1,
"area_m2": _AREA,
}
est = _run_estimate_qi(
analogs=_ANALOGS_OTHER_QUARTER,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
analog_indexes={_OTHER_QUARTER: 1.0},
imv_anchor=thin_anchor,
)
assert est.median_price_rub == round(base_median * 1.2) # 7_200_000
assert est.median_price_rub != round(6_000_000 * 0.5 + 30_000_000 * 0.5) # не blend
assert "квартал" in (est.confidence_explanation or "").lower()
def test_guard1b_thick_market_imv_still_blocks_quarter_index_3323() -> None:
"""#3323 регрессия: market_count >= порога → blend как раньше, поправка подавлена."""
thick_anchor = {
"recommended_price": 30_000_000,
"lower_price": 25_000_000,
"higher_price": 35_000_000,
"market_count": 500,
"rooms": 1,
"area_m2": _AREA,
}
est = _run_estimate_qi(
analogs=_ANALOGS_OTHER_QUARTER,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
analog_indexes={_OTHER_QUARTER: 1.0},
imv_anchor=thick_anchor,
)
assert est.median_price_rub == round(6_000_000 * 0.5 + 30_000_000 * 0.5) # 18_000_000
assert "квартал" not in (est.confidence_explanation or "").lower()
def test_guard1b_imv_anchor_below_blend_threshold_prevents_correction() -> None:
"""Guard-1b: IMV anchor присутствует но ниже blend-порога (blended=False).

View file

@ -159,6 +159,33 @@ def test_wrong_secret_401(client: TestClient, _fake_telegram_client: Any) -> Non
assert _fake_telegram_client.calls == []
def test_secret_accepted_from_header_without_query(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""#3154: секрет можно прислать заголовком — тогда он не течёт в access-log."""
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD, headers={"X-GlitchTip-Secret": _SECRET})
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_wrong_header_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD, headers={"X-GlitchTip-Secret": "wrong-value"})
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_query_secret_still_accepted_as_fallback(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""GlitchTip 6.1.6 заголовков не шлёт вовсе — query-путь обязан работать."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_secret_not_configured_returns_503_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:

View file

@ -16,7 +16,7 @@ import { HTTPError } from "@/lib/api";
import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { Topbar } from "@/components/trade-in/Topbar";
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
import { HeroSummary } from "@/components/trade-in/HeroSummary";
import { HeroSummary, thinMarketNote } from "@/components/trade-in/HeroSummary";
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
@ -276,6 +276,7 @@ export default function TradeInPage() {
<IMVBenchmark
benchmark={imvBenchmark.data}
isLoading={imvBenchmark.isPending}
note={thinMarketNote(resultData.estimate.avito_imv)}
/>
<CianValuationCard data={resultData.estimate.cian_valuation} />
<HouseInfoCard

View file

@ -10,7 +10,7 @@
* 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER см. `lib/legal-copy.ts`.
*/
import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
import type { AggregatedEstimate, AvitoImvSummary, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
@ -18,6 +18,22 @@ import { dealsAsOfLabel } from "@/lib/rosreestr";
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
import { HeroTransparency } from "./HeroTransparency";
/**
* #3339 подпись «тонкого рынка» под числом оценочной модели площадки.
*
* Бэкенд (thin_market, #audit-5b) при market_count ниже порога ИСКЛЮЧАЕТ IMV из
* headline число остаётся справочным. Показывать его рядом с итогом молча
* то же самое, что выдать не участвующую в расчёте величину за расчётную.
* null (нет флага / старая оценка без поля) подписи нет.
*/
export function thinMarketNote(imv: AvitoImvSummary | null | undefined): string | null {
if (imv?.thin_market !== true) return null;
const n = imv.market_count;
if (n == null) return "тонкий рынок — в расчёт не входит";
// После «из» счётная форма родительная у всех чисел, кроме единицы.
return `рынок из ${n} ${n === 1 ? "объявления" : "объявлений"} — в расчёт не входит`;
}
interface Props {
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
@ -175,6 +191,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0
? imv.recommended_price
: null;
// #3339: число IMV показываем, но при тонком рынке честно называем его вне расчёта.
const imvThin = thinMarketNote(imv);
// ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ──
const dkp = estimate.dkp_corridor;
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
@ -537,6 +555,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
<div className="bench-chip">
<span className="bench-chip__src" title="Оценочная модель площадки — независимая оценка рыночной стоимости">{sourcePublicLabel("avito_imv")}</span>
<span className="bench-chip__val mono">{formatMln(imvPrice)} </span>
{imvThin && <span className="bench-chip__sub">{imvThin}</span>}
</div>
)}
{estimate.cian_valuation?.sale_price_rub != null && (
@ -599,6 +618,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
>
{sourcePublicLabel("avito_imv")}, оценочная модель: {formatMln(imvPrice)}
{imvThin ? ` · ${imvThin}` : ""}
</div>
)}
</div>

View file

@ -6,6 +6,13 @@ import { sourcePublicLabel } from "@/lib/source-registry";
interface Props {
benchmark: IMVBenchmarkResponse | undefined;
isLoading: boolean;
/**
* #3339 подпись «тонкого рынка» от thinMarketNote(). Это же число стоит в
* hero, и там оно уже подписано; здесь оно печатается второй раз, поэтому
* подпись обязана доехать и сюда иначе рядом с итогом остаётся неподписанная
* копия величины, исключённой из расчёта.
*/
note?: string | null;
}
const SRC_LABEL = sourcePublicLabel("avito_imv");
@ -16,7 +23,7 @@ function formatRub(n: number | null | undefined): string {
return new Intl.NumberFormat("ru-RU").format(n) + " ₽";
}
export function IMVBenchmark({ benchmark, isLoading }: Props) {
export function IMVBenchmark({ benchmark, isLoading, note }: Props) {
if (isLoading) {
return (
<div className="imv-benchmark imv-benchmark--loading">
@ -59,6 +66,7 @@ export function IMVBenchmark({ benchmark, isLoading }: Props) {
<div className="imv-benchmark__metric-value">
{formatRub(recommended_price)}
</div>
{note && <div className="bench-chip__sub">{note}</div>}
</div>
<div className="imv-benchmark__metric">
<div className="imv-benchmark__metric-label">Диапазон</div>

View file

@ -0,0 +1,113 @@
/**
* #3339 число, исключённое из расчёта, обязано быть подписано.
*
* Бэкенд (#3336, thin_market) при market_count ниже порога не пускает IMV в
* headline: величина остаётся справочной. На витрине она всё равно стоит рядом
* с итоговой ценой без пометки читается как участвующая в расчёте.
*
* Проверяется ЗНАЧЕНИЕ подписи (реальный N из market_count) и оба безопасных
* состояния: флаг false и полное отсутствие поля (оценка со старого бэкенда).
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { HeroSummary, thinMarketNote } from "../HeroSummary";
import { IMVBenchmark } from "../IMVBenchmark";
import {
FIXTURE_ESTIMATE,
FIXTURE_IMV,
FIXTURE_INPUT,
} from "@/app/ui-preview/estimate/fixture";
import type { AggregatedEstimate, AvitoImvSummary } from "@/types/trade-in";
// Бренд и блок прозрачности ходят в сеть (TanStack Query) — к подписи отношения
// не имеют, гасим, чтобы рендер hero был оффлайновым.
vi.mock("@/lib/useBrand", () => ({
useActiveBrandSlug: () => null,
useBrand: () => ({ data: null }),
}));
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
const IMV: AvitoImvSummary = {
recommended_price: 10_200_000,
lower_price: 9_400_000,
higher_price: 11_000_000,
market_count: 3,
};
function estimateWithImv(imv: AvitoImvSummary): AggregatedEstimate {
return { ...FIXTURE_ESTIMATE, avito_imv: imv };
}
function renderHero(imv: AvitoImvSummary) {
return render(
<HeroSummary
estimate={estimateWithImv(imv)}
input={FIXTURE_INPUT}
onResubmit={() => {}}
/>,
);
}
describe("thin_market — подпись у числа оценочной модели площадки", () => {
it("thin_market=true, market_count=3 → подпись называет реальные 3 объявления", () => {
expect(thinMarketNote({ ...IMV, thin_market: true })).toBe(
"рынок из 3 объявлений — в расчёт не входит",
);
const { container } = renderHero({ ...IMV, thin_market: true });
// Число IMV осталось на экране — гасить его нечестно, оно посчитано.
expect(container.textContent).toContain("10,20 млн");
// Но рядом с ним стоит N из market_count, а не общая формулировка.
expect(screen.getAllByText(/рынок из 3 объявлений — в расчёт не входит/).length)
.toBeGreaterThan(0);
});
it("thin_market=false → подписи нет (IMV участвует в расчёте)", () => {
expect(thinMarketNote({ ...IMV, thin_market: false })).toBeNull();
const { container } = renderHero({ ...IMV, thin_market: false });
expect(container.textContent).toContain("10,20 млн");
expect(container.textContent).not.toContain("в расчёт не входит");
});
it("поля нет вовсе (оценка со старого бэкенда) → подписи нет", () => {
expect(thinMarketNote(IMV)).toBeNull();
expect(thinMarketNote(null)).toBeNull();
const { container } = renderHero(IMV);
expect(container.textContent).not.toContain("в расчёт не входит");
});
it("единственное объявление — счётная форма не ломается", () => {
expect(thinMarketNote({ ...IMV, market_count: 1, thin_market: true })).toBe(
"рынок из 1 объявления — в расчёт не входит",
);
});
});
/**
* То же число печатается второй раз в отдельной карточке под hero. Подпись в
* hero её не покрывает: у карточки свой источник данных (imv-benchmark endpoint),
* и без проброса note рядом с итогом остаётся неподписанная копия величины.
*/
describe("IMVBenchmark — подпись доезжает до второго места с тем же числом", () => {
const NOTE = "рынок из 3 объявлений — в расчёт не входит";
it("note передан → подпись стоит под числом", () => {
const { container } = render(
<IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} note={NOTE} />,
);
expect(container.textContent).toContain(NOTE);
});
it("note не передан (рынок не тонкий) → подписи нет", () => {
const { container } = render(
<IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} />,
);
expect(container.textContent).not.toContain("в расчёт не входит");
});
});

View file

@ -846,6 +846,7 @@
.bench-chip__sub {
font-size: 10px;
color: var(--muted);
max-width: 240px;
}
/* ── empty-state headline (без данных) ── */

View file

@ -147,6 +147,10 @@ export interface AvitoImvSummary {
lower_price: number | null; // нижняя граница IMV-коридора, ₽
higher_price: number | null; // верхняя граница IMV-коридора, ₽
market_count: number | null; // объём рынка, на котором построена оценка
// #3339 / backend #audit-5b: market_count < avito_imv_thin_market_threshold —
// IMV построен на малой выборке и в headline НЕ участвует (display-only).
// optional: оценки, посчитанные до деплоя гейта, поля не содержат.
thin_market?: boolean;
}
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──

View file

@ -2112,8 +2112,14 @@ async def run_avito_newbuilding_sweep(
logger.info(
"nb-sweep run_id=%d: SIGTERM-drain — stopping before SERP phase", run_id
)
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, counters.to_dict())
# #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)).
# Без неё оборванный деплоем обход неотличим от полного: статус 'done',
# счётчики частичные — и резюм (`_drained_done` в scheduler) его не берёт.
# done_buckets тут не пишем: heartbeat мержит jsonb, уже записанные
# страницы переживают финализатор.
_drain = {**counters.to_dict(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
# proxy_provider прокинут для консистентности (#2616) — не load-bearing,
@ -2370,8 +2376,11 @@ async def run_yandex_city_sweep(
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, counters.to_dict())
# #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)) —
# см. там же. done_buckets пишет combo-heartbeat, jsonb-мерж их сохраняет.
_drain = {**counters.to_dict(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
logger.info(
@ -2985,8 +2994,11 @@ async def run_cian_city_sweep(
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, counters.to_dict())
# #3333: та же метка дрейна, что у avito_city_sweep (_ckpt(interrupted=1)) —
# см. там же. done_buckets пишет end-of-anchor heartbeat, jsonb-мерж хранит.
_drain = {**counters.to_dict(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
logger.info(