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
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:
commit
7322615868
26 changed files with 1371 additions and 44 deletions
54
caddy/deploy-window.caddy.snippet
Normal file
54
caddy/deploy-window.caddy.snippet
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
|
||||
#
|
||||
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
|
||||
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
|
||||
# когда апстрим не отвечает.
|
||||
#
|
||||
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
|
||||
# оставляет окно 30–90 с, в котором контейнера просто нет: 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
|
||||
}
|
||||
|
|
@ -267,6 +267,32 @@ gendsgn.ru {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
|
||||
# оставляет 30–90 с, в которые контейнера нет и посетитель видит голый 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.
|
||||
|
|
|
|||
|
|
@ -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 пустышкой.
|
||||
|
|
|
|||
|
|
@ -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 (не публичный по построению)" \
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
tradein-mvp/backend/app/core/log_scrub.py
Normal file
72
tradein-mvp/backend/app/core/log_scrub.py
Normal 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())
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
tradein-mvp/backend/tests/test_3154_query_secret_scrub.py
Normal file
135
tradein-mvp/backend/tests/test_3154_query_secret_scrub.py
Normal 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
|
||||
|
|
@ -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:
|
||||
"""Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""
|
||||
|
|
|
|||
212
tradein-mvp/backend/tests/test_3333_drain_mark_all_sweeps.py
Normal file
212
tradein-mvp/backend/tests/test_3333_drain_mark_all_sweeps.py
Normal 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"
|
||||
280
tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py
Normal file
280
tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py
Normal 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; parse→None →
|
||||
failed; save→False → БЫЛА ДЫРА; общий except → failed.
|
||||
avito — save→False → БЫЛА ДЫРА; 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 не должна начислять исход дважды"
|
||||
)
|
||||
|
|
@ -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. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("в расчёт не входит");
|
||||
});
|
||||
});
|
||||
|
|
@ -846,6 +846,7 @@
|
|||
.bench-chip__sub {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
/* ── empty-state headline (без данных) ── */
|
||||
|
|
|
|||
|
|
@ -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, не клампит) ──
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue