diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy
index 085146a4..8695088d 100644
--- a/caddy/sites/apps.caddy
+++ b/caddy/sites/apps.caddy
@@ -175,10 +175,28 @@ gendsgn.ru {
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
- # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
- # из env (Set-операция с непустым значением, никак не связана с auth-гейтом
- # basic_auth), это единственное, что теперь отсекает подделку заголовков
- # изнутри gendesign_shared network для legacy dual-mode пути.
+ # X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
+ # правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
+ # анонимный, до всякого логина trade-in.
+ #
+ # ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
+ # ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
+ # сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
+ # X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
+ # authenticated user»). А X-Authenticated-User на этом же хопе строкой
+ # ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
+ # может по построению, сравнение недостижимо. Второй потребитель
+ # секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
+ # берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
+ # правку не реагирует.
+ #
+ # ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
+ # в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
+ # Инжект работал в обратную сторону: раздавал внутренний секрет по
+ # анонимному пути (он же виден в env контейнера Caddy). Легитимный
+ # dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
+ # -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
+ # см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
@@ -186,7 +204,6 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
- header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@@ -224,9 +241,13 @@ gendsgn.ru {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /trade-in/api/* выше — та же логика (явное
- # удаление вместо Set с пустым {http.auth.user.id}).
+ # удаление вместо Set с пустым {http.auth.user.id}), и по той же
+ # причине здесь больше нет инжекта X-Internal-Auth-Secret
+ # (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
+ # Next-сервер tradein-frontend, в его коде заголовок не читается
+ # и дальше в backend не пробрасывается (SSR не форвардит
+ # входящие заголовки — `headers()` во фронте не используется).
header_up -X-Authenticated-User
- header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@@ -463,7 +484,40 @@ meraocenka.ru {
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
+ #
+ # #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
+ # App Router раскладывает код постранично: `chunks/app/<маршрут>/page-.js`
+ # (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
+ # `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
+ # и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
+ # это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
+ # /sale-share, /login, /ui-preview: страницы закрытого контура, которые
+ # аноним с публичного домена качал целиком и читал в них имена внутренних
+ # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
+ # `not` вместо негативного lookahead, RE2 его не поддерживает.
+ #
+ # `_not-found` в исключениях — это не маршрут продукта, а внутренняя
+ # страница Next (404 при клиентской навигации по битому href). Закрытого
+ # кода не несёт, а без исключения клиентский переход падал бы в
+ # error-boundary вместо честного «не найдено».
+ #
+ # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
+ # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
+ # маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
+ # без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
+ # код», и городить второй матчер ради него смысла нет. Полное разделение —
+ # split build (отдельный public-бандл), отдельная задача.
+ #
+ # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
+ # директив Caddy), поэтому вложенный матчер не зависит от сортировки
+ # handle-блоков между собой.
handle /trade-in/_next/static/* {
+ @foreignRouteChunk {
+ path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
+ not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
+ }
+ respond @foreignRouteChunk 404
+
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
@@ -499,9 +553,12 @@ meraocenka.ru {
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
- # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
- # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
- # анонимного домена — расширять доверие без нужды.
+ # X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
+ # проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
+ # расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
+ # gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
+ # заголовок нужен только внутрисетевому dual-mode трафику, который ходит
+ # мимо прокси и несёт его сам.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh
index 478adcda..a0906279 100644
--- a/scripts/smoke-mera-perimeter.sh
+++ b/scripts/smoke-mera-perimeter.sh
@@ -119,6 +119,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
+# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324):
+# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой
+# правки аноним скачивал с публичного домена бандлы /admin, /team,
+# /scrapers — с именами внутренних ручек внутри.
+#
+# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404,
+# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а
+# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по
+# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой
+# (замер на проде 02.09.2026: 9 байт).
+check_caddy_404() {
+ local desc="$1" url="$2"
+ local out code size
+ out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
+ code=${out%% *}
+ size=${out##* }
+ if [ "$code" = "404" ] && [ "$size" = "0" ]; then
+ echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)"
+ else
+ echo "FAIL: $desc ($url -> got '${out:-}', expected '404 0')"
+ fail=1
+ fi
+}
+
+check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \
+ "$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js"
+check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \
+ "$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js"
+check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
+ "$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js"
+
+# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
+# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
+# успешной, а публичный сайт молча остался бы без JS.
+layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
+ | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
+if [ -z "$layout_chunk" ]; then
+ # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
+ # чём — поэтому это FAIL, а не «пропустим».
+ echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
+ fail=1
+else
+ check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
+fi
+
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py
index d42bd250..1a422822 100644
--- a/tradein-mvp/backend/app/api/v1/team.py
+++ b/tradein-mvp/backend/app/api/v1/team.py
@@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
-from app.core.auth import get_role
+from app.core.auth import get_role, yaml_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
@@ -288,7 +288,9 @@ def _upsert_quota_override(
)
-def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
+def _batch_quota_status(
+ db: Session, usernames: list[str], known_roles: dict[str, str] | None = None
+) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
@@ -351,13 +353,21 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
- try:
- role = get_role(username)
- except KeyError:
- role = None
+ # #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
+ # же строк — иначе батч снова стал бы N+1 (ловит
+ # test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
+ # ровно то, что вернул бы get_role: он спрашивает реестр первым.
+ role: str | None
+ if known_roles is not None and username in known_roles:
+ role = known_roles[username]
+ else:
+ try:
+ role = get_role(username)
+ except KeyError:
+ role = None
if role == "admin":
unlimited = True
- elif role is not None:
+ elif yaml_role(username) is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
@@ -432,6 +442,20 @@ async def create_employee(
`identity_db` — реестр (строка сотрудника), `db` — продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
+ # #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права
+ # (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой
+ # (app.core.auth.get_role), так что эскалации не было бы и без этой
+ # проверки — но совпадение имён само по себе означает двух разных людей с
+ # одним логином, и дешевле отказать на входе, чем разбирать это в логах.
+ legacy = yaml_role(body.username)
+ if legacy is not None and legacy != "employee":
+ logger.warning(
+ "create_employee: %r refused — username занят в roles.yaml (role=%s)",
+ body.username,
+ legacy,
+ )
+ raise HTTPException(status_code=409, detail="username reserved in roles config")
+
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
@@ -762,7 +786,11 @@ async def list_employees(
.all()
)
- quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
+ quota_by_username = _batch_quota_status(
+ db,
+ [row["username"] for row in rows],
+ known_roles={row["username"]: row["role"] for row in rows},
+ )
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py
index affe6c4e..512b924a 100644
--- a/tradein-mvp/backend/app/core/auth.py
+++ b/tradein-mvp/backend/app/core/auth.py
@@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via
When updating one copy, update the other.
+⚠️ РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно):
+здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` /
+`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет,
+там копия остаётся YAML-only.
+
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
и пропускает в backend заголовок `X-Authenticated-User: ` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
@@ -25,13 +30,15 @@ import logging
import re
from functools import lru_cache
from pathlib import Path
-from typing import Literal, TypedDict
+from typing import Literal, TypedDict, cast
import yaml
logger = logging.getLogger(__name__)
-Role = Literal["admin", "pilot", "analyst", "expired"]
+# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK
+# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316).
+Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"]
class UserScope(TypedDict):
@@ -155,8 +162,74 @@ def _load_roles_config() -> dict:
# ---------------------------------------------------------------------------
+def yaml_role(username: str) -> Role | None:
+ """Роль из roles.yaml (без похода в реестр) или None, если юзера там нет.
+
+ Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль:
+ `team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже
+ числятся права.
+ """
+ users: dict[str, Role] = _load_roles_config()["users"]
+ return users.get(username)
+
+
+def _registry_role(username: str) -> str | None:
+ """Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None.
+
+ None означает «реестр про этого юзера ничего не сказал»: строки нет, роль
+ пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
+ остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход.
+
+ ⚠️ Осознанный компромисс (#3316 review): последняя ветка — недоступный
+ реестр — на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно
+ то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий
+ имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в
+ `team.create_employee`. Если коллизия всё же появится (ручной INSERT в
+ реестр, расширение roles.yaml) — сбой БД станет окном эскалации, и тогда
+ эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не
+ дописывать проверки у вызывающих.
+
+ Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
+ едет bind-параметром: снаружи в SQL не попадает ничего.
+ """
+ try:
+ from sqlalchemy import text
+
+ from app.services.identity_store import identity_schema, identity_session
+
+ schema = identity_schema()
+ with identity_session() as db:
+ row = db.execute(
+ text(f"SELECT role FROM {schema.users_table} WHERE username = :username"),
+ {"username": username},
+ ).fetchone()
+ except Exception:
+ logger.exception(
+ "registry role lookup failed for %r — fallback to roles.yaml",
+ username,
+ )
+ return None
+ if row is None or not row.role:
+ return None
+ return str(row.role)
+
+
def get_role(username: str) -> Role:
- """Return the role for *username* or raise KeyError if unknown."""
+ """Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback.
+
+ Raises KeyError, если юзера нет ни там, ни там.
+
+ #3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди
+ заводятся в БД (`tradein_users`) — два дефекта разом. Вверх: сотрудник,
+ чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам +
+ безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError →
+ 403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он
+ здесь — вызывающие (rbac, trade_in, team, account_quota) не меняются.
+ """
+ db_role = _registry_role(username)
+ if db_role is not None:
+ return cast(Role, db_role)
+
config = _load_roles_config()
users: dict[str, Role] = config["users"]
if username not in users:
@@ -217,13 +290,22 @@ def get_user_scope(username: str) -> UserScope:
"""
config = _load_roles_config()
role = get_role(username)
- role_def = config["roles"][role]
+ role_def = config["roles"].get(role)
+ if role_def is None:
+ # Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не
+ # в roles.yaml (#3316: get_role теперь может вернуть и такую роль).
+ from app.services.auth_session import get_db_role_scope
+
+ allowed_paths, deny_paths = get_db_role_scope(role)
+ else:
+ allowed_paths = list(role_def.get("paths", []) or [])
+ deny_paths = list(role_def.get("deny", []) or [])
display_name, org, email = get_profile_for_user(username)
return UserScope(
username=username,
role=role,
- allowed_paths=list(role_def.get("paths", []) or []),
- deny_paths=list(role_def.get("deny", []) or []),
+ allowed_paths=allowed_paths,
+ deny_paths=deny_paths,
brand=get_brand_for_user(username),
display_name=display_name,
org=org,
diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py
index 06f14afc..62701225 100644
--- a/tradein-mvp/backend/app/core/rbac.py
+++ b/tradein-mvp/backend/app/core/rbac.py
@@ -38,7 +38,7 @@ from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
-from app.services.auth_session import get_db_role_scope, get_session_user
+from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
from app.services.identity_store import identity_session
logger = logging.getLogger(__name__)
@@ -334,7 +334,11 @@ async def rbac_guard(
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
- if from_session:
+ # Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
+ # с DB-first резолвом legacy trusted-header путь тоже может отдать роль
+ # реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
+ # с `is_path_allowed` дала бы 403 на всё.
+ if from_session or role in DB_ROLE_PATHS:
allowed = _db_role_path_allowed(role, external_path)
else:
try:
diff --git a/tradein-mvp/backend/app/services/account_quota.py b/tradein-mvp/backend/app/services/account_quota.py
index 10d778ca..7f420657 100644
--- a/tradein-mvp/backend/app/services/account_quota.py
+++ b/tradein-mvp/backend/app/services/account_quota.py
@@ -46,7 +46,7 @@ from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
-from app.core.auth import get_role
+from app.core.auth import get_role, yaml_role
from app.core.config import settings
logger = logging.getLogger(__name__)
@@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str:
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
anon default_limit, см. #b2c-antiabuse-2)."""
return (
- f"Лимит из {limit} оценок в этом месяце исчерпан. "
- "За полной версией обращайтесь к Копылову."
+ f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
)
@@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool:
return False
if role == "admin":
return True
+ # #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
+ # team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
+ # осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
+ # раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
+ # для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
+ # `_batch_quota_status` в списке «Команды»).
+ if yaml_role(username) is None:
+ return False
row = db.execute(
text(
"""
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index 3541e3fd..a45b6a4e 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -3359,7 +3359,10 @@ def _price_from_inputs(
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
- anchor_label = "оценке Avito IMV"
+ # Публичная копия: клиент видит эту строку в confidence_explanation
+ # (HeroSummary), название площадки туда не должно утекать —
+ # та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
+ anchor_label = "оценочной модели площадки"
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
_imv_mc = (
int(imv_anchor["market_count"])
@@ -3396,7 +3399,10 @@ def _price_from_inputs(
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
- anchor_label = "оценке Avito IMV"
+ # Публичная копия: клиент видит эту строку в confidence_explanation
+ # (HeroSummary), название площадки туда не должно утекать —
+ # та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
+ anchor_label = "оценочной модели площадки"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index 341fea48..7c5125ae 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -290,8 +290,9 @@ matplotlib.rcParams["font.family"] = "Manrope"
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
-# через безопасный fallback в _source_pill: серый фон + source.title() — код на
-# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
+# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 —
+# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется)
+# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
@@ -303,15 +304,59 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"etazhi": ("#e30613", "#fff"), # Этажи красный
}
+# Публичные лейблы — канон `frontend/src/lib/source-registry.ts::SOURCES` (#3341,
+# класс #3264): клиентский PDF не должен называть площадки-источники так же, как
+# веб-отчёт и лендинг перестали делать это с 31.08. Ключи — ТОЛЬКО базовые id
+# (алиасы валюации/написания резолвятся через _SOURCE_CANONICAL/_canonical_source
+# ДО обращения сюда — иначе два источника правды для одной площадки).
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
- "avito": "Avito",
- "cian": "Циан",
- "domklik": "Домклик · Сбер",
- "yandex": "Я.Недвижимость",
+ "avito": "Источник 1",
+ "cian": "Источник 2",
+ "yandex": "Источник 3",
+ "domklik": "Источник 4",
+ "etazhi": "Источник 5",
"rosreestr": "Росреестр",
- "etazhi": "Этажи",
}
+# Алиас → базовый id. Зеркалит группировку publicLabel/dot в
+# frontend/src/lib/source-registry.ts (одна площадка = один номер/цвет там же);
+# правишь один реестр — проверь другой (#3341 review: без канонизации
+# estimate.sources_used = [avito, avito_imv, cian, cian_valuation, ...]
+# (estimator.py `_canonical_sources`, listing+valuation union) рендерил
+# ДВЕ одинаковые пилюли «Источник 1, Источник 1» и серую точку у алиаса —
+# ни avito_imv/cian_valuation/yandex_valuation/domclick/etagi не было ключом
+# ни в _SOURCE_LOGO_COLORS, ни (после дедупа лейблов) в _SOURCE_DISPLAY_NAMES).
+_SOURCE_CANONICAL: dict[str, str] = {
+ "avito_imv": "avito",
+ "cian_valuation": "cian",
+ "yandex_valuation": "yandex",
+ "domclick": "domklik",
+ "etagi": "etazhi",
+}
+
+
+def _canonical_source(source: str) -> str:
+ """Алиас (valuation-вариант / альтернативное написание) → базовый id площадки."""
+ return _SOURCE_CANONICAL.get(source, source)
+
+
+def _public_sources(ids: list[str]) -> list[str]:
+ """Канонизирует id и дедуплицирует с сохранением порядка первого появления.
+
+ `estimate.sources_used` — отсортированное объединение listing-id и их
+ valuation-алиасов (estimator.py `_canonical_sources`), напр.
+ `[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]`.
+ Без канонизации+дедупа ДО среза `[:5]` рендерились дубли лейблов и «Источник 3»
+ (yandex) мог быть вытеснен алиасом другой площадки (#3341 review)."""
+ seen: set[str] = set()
+ result: list[str] = []
+ for raw in ids:
+ canon = _canonical_source(raw)
+ if canon not in seen:
+ seen.add(canon)
+ result.append(canon)
+ return result
+
def _source_logo_pill(source: str) -> str:
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
@@ -319,10 +364,12 @@ def _source_logo_pill(source: str) -> str:
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) —
- overflow-wrap на всякий случай, если source незнаком и попадёт .title()
- произвольной длины (см. fallback ниже)."""
- dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
- name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
+ overflow-wrap на всякий случай, если source незнаком (см. fallback ниже).
+ Канонизирует алиасы (avito_imv → avito и т.п.) ДО lookup, чтобы цвет/лейбл
+ совпадали с базовой площадкой (#3341 review)."""
+ canon = _canonical_source(source)
+ dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0]
+ name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
"—"
- bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
- name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
+ canon = _canonical_source(source)
+ bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))
+ name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
f"{_html.escape(name)}"
@@ -1167,12 +1216,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
- f"ДомКлик и продажи агентств недвижимости"
+ f"сделки площадок и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
- "Росреестр, ДомКлик и продажи агентств недвижимости"
+ "Росреестр, сделки площадок и продажи агентств недвижимости"
)
disclaimer_html = ""
@@ -1357,7 +1406,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
# count «с учётом ремонта» не существует, второе число было идентично n_total.
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
- sources_to_show = estimate.sources_used or []
+ # #3341 review: sources_used — union listing+valuation алиасов (estimator.py
+ # `_canonical_sources`), напр. [avito, avito_imv, cian, cian_valuation, ...] —
+ # _public_sources канонизирует+дедуплицирует ДО среза [:5], иначе дубли пилюль
+ # и «Источник 3» мог быть вытеснен алиасом другой площадки.
+ sources_to_show = _public_sources(estimate.sources_used or [])
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
@@ -1598,10 +1651,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
+ # #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же
+ # причины, что и на странице листингов (см. коммент там).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
- deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
+ canonical_sources = _public_sources(estimate.sources_used or [])
+ deal_sources = [s for s in canonical_sources if s in _deal_source_keys]
if not deal_sources:
- deal_sources = [s for s in (estimate.sources_used or [])]
+ deal_sources = canonical_sources
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
area = float(input_snapshot.get("area_m2", 0) or 0)
@@ -1847,7 +1903,7 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
Расходы на рекламу
Ежемесячное базовое продвижение объекта
- на Циан, Авито, Я.Недвижимости
+ на основных площадках объявлений
бесплатно
diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
index a6e4a11e..4d35cd87 100644
--- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
@@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
+ else:
+ # #3332, та же дыра в тождестве, что и у пустого пула: карточку
+ # забрали, а строки уже нет (удалена/деактивирована между
+ # снимком и UPDATE) — попытка была, исхода не было. Тихо
+ # терять её нельзя: расхождение читается как потерянный блок.
+ counters.failed += 1
+ logger.warning(
+ "domclick_detail_backfill: run_id=%d listing %s — карточка "
+ "разобрана, но UPDATE не нашёл строку id=%d",
+ run_id,
+ source_url,
+ listing_id,
+ )
consecutive_blocks = 0
consecutive_soft = 0
@@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
if _caused_by_empty_pool(e):
+ # #3332: попытка уже посчитана в attempted (строка выше), а этот
+ # выход из цикла шёл мимо ВСЕХ исходов — тождество
+ # attempted = enriched + failed + blocked ломалось ровно на 1
+ # (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
+ # Исход честно failed, а не blocked: к площадке не ходили, это
+ # отказ нашей стороны — тот же разряд, что у транспортных сбоев
+ # ниже. Причина не теряется: в записи прогона стоит
+ # no_proxy_stop=1 и mark_failed с текстом про пул.
+ counters.failed += 1
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
index 881f3728..d36fb741 100644
--- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
+++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
@@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
- "Строка не попадает на витрину, только если данных нет: оценщик не дал "
+ "Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
- "витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
+ "витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
@@ -123,7 +123,7 @@ NOTE = (
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
- "строка выглядит как промах оценщика, хотя врёт документ. "
+ "строка выглядит как промах расчёта, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
diff --git a/tradein-mvp/backend/app/tasks/landing_stats.py b/tradein-mvp/backend/app/tasks/landing_stats.py
index 4a9cdfc8..e02450b8 100644
--- a/tradein-mvp/backend/app/tasks/landing_stats.py
+++ b/tradein-mvp/backend/app/tasks/landing_stats.py
@@ -305,9 +305,12 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
).first()
if row is not None and row.n:
n = int(row.n)
+ # Публичная подпись метрики: площадка не называется (норма publicLabel,
+ # решение 31.08.2026); фактически это Домклик — единственный источник,
+ # где триггер пишет стартовую цену. Текст персистится в landing_stats.note.
base_note = (
- f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
- f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
+ "Только одна из площадок (единственный источник, где фиксируется "
+ f"стартовая цена), наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
)
metrics.append(
diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
index 6a4ce8be..65c56b3c 100644
--- a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
+++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
@@ -194,7 +194,7 @@ def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
"""
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
- assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
+ assert "скорректирована по оценочной модели площадки" not in (est.confidence_explanation or "")
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
new file mode 100644
index 00000000..a238119b
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
@@ -0,0 +1,225 @@
+"""Чекпоинт avito_city_sweep доживает до финализатора (#3319).
+
+Точку писала ровно одна строка — end-of-anchor heartbeat в конце итерации цикла
+якорей. Финализаторы её не стирали (все писатели в runs.py мержат jsonb:
+`counters || :counters`), дыра в другом: выходы, случившиеся РАНЬШЕ первой такой
+записи, точки не оставляли вовсе — cancel/SIGTERM-дрейн на границе первого якоря
+и ранний done #1950 («SERP собран, detail заблокирован») на якоре №1. Ими и
+кончается типичный прод-прогон с `anchors_done: 1` из 5.
+
+Замер «0 из 67 прогонов за 60 дней несут done_buckets» тут НЕ доказательство:
+строка записи появилась только 26.08.2026 (#3074) при такте avito 7 суток —
+выборка почти целиком из эры, где механизма не существовало.
+
+Три инварианта, ради которых тест:
+ 1. done-выход несёт done_buckets — иначе точка существует только в логе.
+ 2. Якорь, умерший по таймауту, НЕ пройден: SERP мог успеть, detail нет.
+ Пройденным его записать = резюм пропустит его навсегда и молча.
+ 3. SIGTERM-дрейн отличим от полного обхода (counters.interrupted=1) и
+ участвует в резюме — статус у обоих 'done', счётчики частичные.
+"""
+
+from __future__ import annotations
+
+import os
+
+# Settings собирается автофикстурой conftest'а и требует database_url. Выставляем
+# до остальных импортов — так же, как в test_3074_avito_anchor_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 _FakeScraper:
+ """Двойник AvitoScraper: помнит визиты, роняет заданный якорь заданной ошибкой."""
+
+ visited: list[tuple[float, float]] = [] # noqa: RUF012 — тестовый сборник
+ raise_on: tuple[float, float] | None = None
+ exc: type[BaseException] | None = None
+ lots_per_anchor: int = 0
+
+ def __init__(self, *_a: Any, **_kw: Any) -> None:
+ self._browser = None
+ self._cffi = None
+
+ async def fetch_around(self, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
+ _FakeScraper.visited.append((lat, lon))
+ if _FakeScraper.raise_on == (lat, lon) and _FakeScraper.exc is not None:
+ raise _FakeScraper.exc("якорь сорвался")
+ return [MagicMock() for _ in range(_FakeScraper.lots_per_anchor)]
+
+
+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,
+ )
+
+
+async def _run(
+ *,
+ raise_on: tuple[float, float] | None = None,
+ exc: type[BaseException] | None = None,
+ shutdown_after_first: bool = False,
+ saved: tuple[int, int] = (0, 0),
+ lots_per_anchor: int = 0,
+) -> _FakeDb:
+ from scraper_kit.orchestration import pipeline as pl
+
+ _FakeScraper.visited = []
+ _FakeScraper.raise_on = raise_on
+ _FakeScraper.exc = exc
+ _FakeScraper.lots_per_anchor = lots_per_anchor
+ db = _FakeDb()
+
+ def _shutdown() -> bool:
+ return shutdown_after_first and bool(_FakeScraper.visited)
+
+ with (
+ patch.object(pl, "AvitoScraper", _FakeScraper),
+ patch.object(pl, "AsyncSession", _FakeAsyncSession),
+ patch.object(pl, "save_listings", lambda *_a, **_kw: saved),
+ patch.object(pl.runs, "is_cancelled", lambda *_a: False),
+ ):
+ await pl.run_avito_city_sweep(
+ db, # type: ignore[arg-type]
+ run_id=3319,
+ config=_config(),
+ matcher=MagicMock(),
+ enrichment=MagicMock(),
+ anchors=[ANCHOR_A, ANCHOR_B],
+ enrich_houses=False,
+ enrich_imv=False,
+ detail_top_n=0,
+ shutdown_requested=_shutdown,
+ )
+ return db
+
+
+@pytest.mark.asyncio
+async def test_done_exit_carries_checkpoint() -> None:
+ """Финализатор полного обхода несёт done_buckets, а не голые счётчики."""
+ db = await _run()
+
+ assert db.writes[-1].get("done_buckets") == ["ekb-center", "ekb-south"], (
+ "финальный (done) выход отдал counters без чекпоинта — точки в прогоне нет"
+ )
+
+
+@pytest.mark.asyncio
+async def test_serp_ok_done_exit_carries_checkpoint() -> None:
+ """Ранний done-выход #1950 («SERP собран, detail заблокирован») — тоже.
+
+ Именно этим выходом кончается типичный прод-прогон, и он происходит РАНЬШЕ
+ единственной строки, которая писала точку.
+ """
+ from scraper_kit.orchestration import pipeline as pl
+
+ db = await _run(
+ raise_on=(ANCHOR_B[0], ANCHOR_B[1]),
+ exc=pl.AvitoBlockedError,
+ saved=(1, 0), # SERP intake > 0 → ветка ставит 'done', а не 'banned'
+ lots_per_anchor=1,
+ )
+
+ last = db.writes[-1]
+ assert "enrichment_abort_note" in last, "сработала не та ветка выхода"
+ assert last.get("done_buckets") == ["ekb-center"], (
+ "ранний done-выход потерял якорь, пройденный до блокировки"
+ )
+
+
+@pytest.mark.asyncio
+async def test_timed_out_anchor_is_not_checkpointed() -> None:
+ """Якорь, умерший по таймауту, не считается пройденным.
+
+ Иначе резюм пропустит его навсегда, и это будет незаметно: прогон
+ завершается штатно, просто часть города не собирается никогда.
+ """
+ db = await _run(raise_on=(ANCHOR_A[0], ANCHOR_A[1]), exc=TimeoutError)
+
+ ckpt = db.writes[-1].get("done_buckets")
+ assert "ekb-center" not in ckpt, "якорь-таймаут попал в чекпоинт"
+ assert "ekb-south" in ckpt, "исправный якорь не зафиксирован"
+
+
+@pytest.mark.asyncio
+async def test_drain_exit_is_distinguishable_from_full_done() -> None:
+ """SIGTERM-дрейн помечен interrupted=1; полный обход — нет."""
+ drained = await _run(shutdown_after_first=True)
+ full = await _run()
+
+ assert drained.writes[-1].get("interrupted") == 1, (
+ "оборванный дрейном прогон неотличим от полного обхода"
+ )
+ assert drained.writes[-1].get("done_buckets") == ["ekb-center"]
+ assert "interrupted" not in full.writes[-1], "полный обход помечен как оборванный"
+
+
+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_drained_done_is_resumable_but_clean_done_is_not() -> None:
+ """Метка дрейна доходит до решения о резюме — иначе она диагностика ради себя."""
+ from scraper_kit.orchestration.scheduler import _resume_decision
+
+ ckpt = {"done_buckets": ["ekb-center"], "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, "полный обход подхватывать нечего"
+ assert verdict["resume_reason"] == "status_done"
+
+ # Прогон из эры до #3074: ключей нет вовсе — метка дрейна не должна менять
+ # вердикт «нечего подхватывать» на что-то другое.
+ _, verdict = _resume_decision(_prev_run({}))
+ assert verdict["resume_reason"] == "status_done"
+ _, verdict = _resume_decision(_prev_run({"interrupted": 1}))
+ assert verdict["resume_reason"] == "no_checkpoint"
diff --git a/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
new file mode 100644
index 00000000..feb35609
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
@@ -0,0 +1,179 @@
+"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332).
+
+`counters.attempted` инкрементируется ДО попытки, исход дописывается уже в
+разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool` →
+`no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому
+текущая попытка оставалась без исхода: прод дал 5 прогонов с
+`attempted - (enriched + failed + blocked) == 1`. Расхождение читается как
+потерянный отказ площадки, хотя площадку не трогали вовсе.
+
+Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша
+инфраструктура — тот же разряд, что у транспортных сбоев (`_is_transport_failure`,
+#3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не
+подменяет.
+
+Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул
+False — карточка разобрана, но строки уже нет; попытка была, исхода не было.
+
+Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение».
+Харнесс зеркалит tests/test_3196_domclick_ban_kind.py.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from datetime import UTC, datetime, timedelta
+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.domclick_exceptions import DomClickBlockedError # noqa: E402
+from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
+
+from app.tasks.domclick_detail_backfill import ( # noqa: E402
+ DomClickDetailBackfillResult,
+ run_domclick_detail_backfill,
+)
+
+_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
+_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
+_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
+_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
+_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
+_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
+_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
+
+
+def _make_snapshot(n: int) -> list[dict]:
+ return [
+ {
+ "id": i + 1,
+ "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
+ }
+ for i in range(n)
+ ]
+
+
+def _mock_db(snapshot: list[dict]) -> MagicMock:
+ db = MagicMock()
+ sel = MagicMock()
+ sel.mappings.return_value.all.return_value = snapshot
+ db.execute.return_value = sel
+ return db
+
+
+def _mock_session_svc() -> MagicMock:
+ svc = MagicMock()
+ svc.load_session.return_value = {"CAS_ID": "123"}
+ svc.COOKIE_EXPIRY_WARN_DAYS = 5
+ svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
+ return svc
+
+
+def _mock_browser_fetcher_cls() -> MagicMock:
+ instance = AsyncMock()
+ instance.__aenter__ = AsyncMock(return_value=instance)
+ instance.__aexit__ = AsyncMock(return_value=False)
+ instance.request_context_reset = MagicMock()
+ return MagicMock(return_value=instance)
+
+
+def _empty_pool_block() -> DomClickBlockedError:
+ """Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в
+ DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в
+ __cause__ и снаружи выглядит блокировкой."""
+ blocked = DomClickBlockedError("browser fetch failed")
+ blocked.__cause__ = NoProxyAvailableError("domclick")
+ return blocked
+
+
+async def _run(
+ fetch: AsyncMock, *, snapshot: int, save_ok: bool = True
+) -> tuple[DomClickDetailBackfillResult, MagicMock]:
+ db = _mock_db(_make_snapshot(snapshot))
+ runs = MagicMock()
+ with (
+ patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
+ patch(_SESSION_SVC, _mock_session_svc()),
+ patch(_RUNS, runs),
+ patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
+ patch(_FETCH, fetch),
+ patch(_SAVE, return_value=save_ok),
+ patch(_SLEEP, new_callable=AsyncMock),
+ ):
+ counters = await run_domclick_detail_backfill(
+ db,
+ run_id=3332,
+ params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5},
+ )
+ return counters, runs
+
+
+def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None:
+ outcomes = counters.enriched + counters.failed + counters.blocked
+ assert counters.attempted == expected_attempted, (
+ f"attempted={counters.attempted}, ожидали {expected_attempted} попыток"
+ )
+ assert counters.attempted == outcomes, (
+ f"тождество нарушено: attempted={counters.attempted}, "
+ f"сумма исходов={outcomes} (enriched={counters.enriched} "
+ f"failed={counters.failed} blocked={counters.blocked}), "
+ f"потеряно {counters.attempted - outcomes} попыток без исхода"
+ )
+
+
+@pytest.mark.asyncio
+async def test_empty_pool_mid_run_keeps_identity() -> None:
+ """2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed."""
+ fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()])
+ counters, runs = await _run(fetch, snapshot=5)
+
+ _assert_identity(counters, expected_attempted=3)
+ assert counters.failed == 1, (
+ f"failed={counters.failed}: оборванная пустым пулом попытка должна быть "
+ "засчитана отказом НАШЕЙ стороны"
+ )
+ assert counters.blocked == 0, (
+ f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — "
+ "начислять блок значит лгать про причину (#3283)"
+ )
+ # Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком.
+ runs.mark_failed.assert_called_once()
+ assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1
+
+
+@pytest.mark.asyncio
+async def test_missing_row_on_save_keeps_identity() -> None:
+ """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
+ fetch = AsyncMock(return_value=MagicMock())
+ counters, _ = await _run(fetch, snapshot=2, save_ok=False)
+
+ _assert_identity(counters, expected_attempted=2)
+ assert counters.enriched == 0 and counters.failed == 2, (
+ f"enriched={counters.enriched} failed={counters.failed}: "
+ "непрошедший UPDATE — не успех и не блок"
+ )
+
+
+@pytest.mark.asyncio
+async def test_blocked_and_enriched_counted_once() -> None:
+ """Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""
+ fetch = AsyncMock(
+ side_effect=[
+ MagicMock(),
+ DomClickBlockedError("refused", status=403),
+ MagicMock(),
+ ]
+ )
+ counters, _ = await _run(fetch, snapshot=3)
+
+ _assert_identity(counters, expected_attempted=3)
+ assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), (
+ f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, "
+ "ожидали 2/1/0 — правка #3332 не должна начислять исход дважды"
+ )
diff --git a/tradein-mvp/backend/tests/test_pdf_public_source_labels.py b/tradein-mvp/backend/tests/test_pdf_public_source_labels.py
new file mode 100644
index 00000000..2be05837
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_pdf_public_source_labels.py
@@ -0,0 +1,267 @@
+"""Gate test (#3341): клиентский PDF не должен называть реальные площадки-источники.
+
+Канон — `frontend/src/lib/source-registry.ts::SOURCES` (публичные лейблы, закреплены
+для веб-отчёта/лендинга с 31.08, #3264-scope): avito/avito_imv → «Источник 1»,
+cian/cian_valuation → «Источник 2», yandex/yandex_valuation → «Источник 3»,
+domklik/domclick → «Источник 4», etagi/etazhi → «Источник 5», rosreestr → «Росреестр»,
+неизвестный id → «Другой источник» (НЕ сырой id — им тоже может утечь имя площадки).
+
+WeasyPrint стаблен (как в test_pdf_security.py) — тесты дергают только HTML-билдеры.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import sys
+from datetime import UTC, datetime, timedelta
+from uuid import uuid4
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from unittest.mock import MagicMock
+
+_wp_mock = MagicMock()
+sys.modules.setdefault("weasyprint", _wp_mock)
+
+import pytest # noqa: E402
+
+from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
+from app.services.brand import Brand # noqa: E402
+from app.services.exporters import trade_in_pdf as mod # noqa: E402
+
+_GENERIC = Brand(
+ slug="generic",
+ name="Trade-In",
+ logo_url=None,
+ primary_color="#1d4ed8",
+ accent_color="#f59e0b",
+ footer_text=None,
+ pdf_disclaimer=None,
+)
+
+_SNAPSHOT = {
+ "address": "Екатеринбург, ул. Ленина, 1",
+ "area_m2": 50.0,
+ "rooms": 2,
+ "floor": 3,
+ "total_floors": 9,
+ "year_built": 2010,
+ "house_type": "panel",
+ "repair_state": "standard",
+ "has_balcony": True,
+}
+
+# Все известные id источников (листинги + valuation-варианты + сделки) + неизвестный.
+_ALL_SOURCE_IDS = [
+ "avito",
+ "avito_imv",
+ "cian",
+ "cian_valuation",
+ "yandex",
+ "yandex_valuation",
+ "domklik",
+ "domclick",
+ "etazhi",
+ "rosreestr",
+]
+
+# Запрещённые в видимом тексте PDF реальные имена площадок (#pdf-honesty/#3264).
+_FORBIDDEN_NAMES = [
+ "Avito",
+ "AVITO",
+ "Авито",
+ "Циан",
+ "ЦИАН",
+ "Cian",
+ "ДомКлик",
+ "Домклик",
+ "ДОМКЛИК",
+ "Domclick",
+ "Я.Недвижимость",
+ "Яндекс",
+ "Yandex",
+ "Этажи",
+ "Avito_Imv",
+ "Rosreestr",
+]
+
+
+def _analog(**overrides) -> AnalogLot:
+ base = dict(
+ address="ул. Тест, 1",
+ area_m2=50.0,
+ rooms=2,
+ floor=3,
+ total_floors=9,
+ price_rub=5_000_000,
+ price_per_m2=100_000,
+ listing_date=None,
+ days_on_market=None,
+ source="avito",
+ source_url="https://avito.ru/1",
+ distance_m=200,
+ )
+ base.update(overrides)
+ return AnalogLot(**base)
+
+
+def _estimate(**overrides) -> AggregatedEstimate:
+ base = dict(
+ estimate_id=uuid4(),
+ median_price_rub=10_000_000,
+ range_low_rub=9_000_000,
+ range_high_rub=11_000_000,
+ median_price_per_m2=200_000,
+ confidence="high",
+ n_analogs=15,
+ period_months=24,
+ analogs=[],
+ actual_deals=[],
+ expires_at=datetime.now(UTC) + timedelta(days=30),
+ )
+ base.update(overrides)
+ return AggregatedEstimate(**base)
+
+
+def _strip_tags(html: str) -> str:
+ """Видимый текст без разметки — href/src (легальные URL объявлений) не проверяем."""
+ return re.sub(r"<[^>]+>", " ", html)
+
+
+# ── Part A: helper-функции напрямую (source-pill / badge fallback) ───────────
+
+
+@pytest.mark.parametrize("forbidden", _FORBIDDEN_NAMES)
+def test_source_logo_pill_never_leaks_forbidden_names(forbidden: str) -> None:
+ """Ни один известный id, ни неизвестный не рендерят реальное имя площадки."""
+ html = "".join(mod._source_logo_pill(sid) for sid in [*_ALL_SOURCE_IDS, "n1"])
+ assert forbidden not in html
+
+
+@pytest.mark.parametrize("forbidden", _FORBIDDEN_NAMES)
+def test_source_badge_inline_never_leaks_forbidden_names(forbidden: str) -> None:
+ html = "".join(mod._source_badge_inline(sid) for sid in [*_ALL_SOURCE_IDS, "n1"])
+ assert forbidden not in html
+
+
+def test_source_logo_pill_known_ids_map_to_public_labels() -> None:
+ assert "Источник 1" in mod._source_logo_pill("avito")
+ assert "Источник 1" in mod._source_logo_pill("avito_imv")
+ assert "Источник 2" in mod._source_logo_pill("cian")
+ assert "Источник 2" in mod._source_logo_pill("cian_valuation")
+ assert "Источник 3" in mod._source_logo_pill("yandex")
+ assert "Источник 3" in mod._source_logo_pill("yandex_valuation")
+ assert "Источник 4" in mod._source_logo_pill("domklik")
+ assert "Источник 4" in mod._source_logo_pill("domclick")
+ assert "Источник 5" in mod._source_logo_pill("etazhi")
+ assert "Источник 5" in mod._source_logo_pill("etagi")
+ assert "Росреестр" in mod._source_logo_pill("rosreestr")
+
+
+def test_source_logo_pill_unknown_id_falls_back_to_other_source() -> None:
+ """Неизвестный source → «Другой источник», НЕ сырой id (сам тоже мог бы назвать площадку)."""
+ html = mod._source_logo_pill("something_new")
+ assert "Другой источник" in html
+ assert "Something_New" not in html
+ assert "something_new" not in html
+
+
+def test_source_badge_inline_unknown_id_falls_back_to_other_source() -> None:
+ html = mod._source_badge_inline("something_new")
+ assert "Другой источник" in html
+ assert "Something_New" not in html
+ assert "something_new" not in html
+
+
+# ── Part B: билдеры страниц (listings/deals/cover/offer) end-to-end ──────────
+
+
+def test_listings_page_all_sources_no_forbidden_names() -> None:
+ analogs = [
+ _analog(source=sid, source_url=f"https://example.org/{i}", distance_m=i * 10)
+ for i, sid in enumerate([*_ALL_SOURCE_IDS, "n1"])
+ ]
+ est = _estimate(sources_used=_ALL_SOURCE_IDS, analogs=analogs, n_analogs=len(analogs))
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ text = _strip_tags(html)
+ for forbidden in _FORBIDDEN_NAMES:
+ assert forbidden not in text, f"{forbidden!r} leaked into listings page"
+ # Listings page shows only listing sources (first 5 of sources_used/analogs by
+ # distance) — rosreestr (deals) legitimately doesn't reach it here, see deals-page
+ # test below for that assertion.
+ assert "Источник 1" in text
+
+
+def test_listings_page_dedupes_alias_sources_into_single_pill() -> None:
+ """estimator.py `_canonical_sources` — sources_used — отсортированное union
+ listing-id + их valuation-алиасов, напр.
+ [avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]
+ (#3341 review). Листинги должны рендерить РОВНО одну пилюлю на канонический
+ источник (не по одной на алиас), и алиас обязан нести цвет точки базовой
+ площадки, не серый fallback."""
+ sources_used = [
+ "avito",
+ "avito_imv",
+ "cian",
+ "cian_valuation",
+ "domklik",
+ "yandex",
+ "yandex_valuation",
+ ]
+ est = _estimate(sources_used=sources_used)
+ html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
+ for label in ("Источник 1", "Источник 2", "Источник 3", "Источник 4"):
+ count = html.count(label)
+ assert count == 1, f"{label!r} should render exactly once, got {count}"
+ # avito_imv (valuation-алиас) обязан нести тот же бренд-цвет точки, что и avito.
+ assert "#00aaff" in mod._source_logo_pill("avito")
+ assert "#00aaff" in mod._source_logo_pill("avito_imv")
+
+
+def test_deals_page_all_sources_no_forbidden_names() -> None:
+ deals = [
+ _analog(source=sid, source_url=f"https://example.org/{i}", distance_m=i * 10)
+ for i, sid in enumerate(["etazhi", "domklik", "rosreestr", "n1"])
+ ]
+ est = _estimate(
+ sources_used=["etazhi", "domklik", "rosreestr"],
+ actual_deals=deals,
+ )
+ html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC)
+ text = _strip_tags(html)
+ for forbidden in _FORBIDDEN_NAMES:
+ assert forbidden not in text, f"{forbidden!r} leaked into deals page"
+ assert "Росреестр" in text
+
+
+def test_cover_advice_discount_text_no_forbidden_names_without_pct() -> None:
+ """Ветка без expected_sold_price_rub (_discount_pct → None) — только else-текст."""
+ est = _estimate()
+ html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
+ text = _strip_tags(html)
+ assert "ДомКлик" not in text
+ assert "сделки площадок" in text
+ assert "Росреестр" in text
+
+
+def test_cover_advice_discount_text_no_forbidden_names_with_pct() -> None:
+ """С expected_sold_price_rub _discount_pct возвращает %, покрываем именно
+ эту f-строку (review #3341: без sold-цены тест не доходил до неё)."""
+ est = _estimate(expected_sold_price_rub=9_000_000)
+ html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
+ text = _strip_tags(html)
+ assert "на 10%" in text
+ assert "сделки площадок" in text
+ assert "ДомКлик" not in text
+ assert "Росреестр" in text
+
+
+def test_offer_page_advertising_row_no_brand_names() -> None:
+ est = _estimate()
+ html = mod._build_offer_page(est, _SNAPSHOT, _GENERIC)
+ text = _strip_tags(html)
+ assert "на основных площадках объявлений" in text
+ assert "Циан" not in text
+ assert "Авито" not in text
+ assert "Я.Недвижимости" not in text
diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py
index 7789b8e0..633133c7 100644
--- a/tradein-mvp/backend/tests/test_pdf_security.py
+++ b/tradein-mvp/backend/tests/test_pdf_security.py
@@ -381,13 +381,14 @@ def test_listings_page_no_crash_with_no_sources() -> None:
def test_listings_sources_from_estimate_not_hardcoded() -> None:
- """sources_used=['yandex'] → только yandex pill, не avito/cian/domklik/..."""
+ """sources_used=['yandex'] → только источник 3 (yandex) pill, не 1/2/4/... (#3341:
+ публичные лейблы, не реальные названия площадок)."""
est = _estimate(sources_used=["yandex"])
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
- assert "Я.Недвижимость" in html
+ assert "Источник 3" in html
# avito and cian should NOT appear if not in sources_used
- assert "Avito" not in html
- assert "Циан" not in html
+ assert "Источник 1" not in html
+ assert "Источник 2" not in html
def test_deals_sources_from_estimate_not_hardcoded() -> None:
@@ -395,9 +396,9 @@ def test_deals_sources_from_estimate_not_hardcoded() -> None:
est = _estimate(sources_used=["rosreestr"])
html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC)
assert "Росреестр" in html
- # Этажи/Домклик — не должны появиться
- assert "Этажи" not in html
- assert "Домклик" not in html
+ # источник 5 (этажи) / источник 4 (домклик) — не должны появиться (#3341)
+ assert "Источник 5" not in html
+ assert "Источник 4" not in html
def test_examples_rows_none_price_per_m2_no_crash() -> None:
diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py
index ea0837dd..dc3ecc0b 100644
--- a/tradein-mvp/backend/tests/test_rbac.py
+++ b/tradein-mvp/backend/tests/test_rbac.py
@@ -42,6 +42,23 @@ def _reset_auth_cache() -> None:
auth_mod.reset_cache_for_tests()
+@pytest.fixture(autouse=True)
+def _legacy_yaml_only(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Реестр в ЭТОМ файле молчит — здесь проверяется legacy-путь roles.yaml.
+
+ #3316 сделал `get_role` DB-first (реестр → YAML-fallback), и без этой
+ изоляции результат файла зависел бы от ОКРУЖЕНИЯ: локально без БД шла
+ YAML-ветка и всё было зелено, а в CI, где реестр засеян миграцией 193
+ (kopylov=manager, user*=employee), те же ассерты краснели. Тест, который
+ отвечает по-разному в двух окружениях, не проверяет ничего.
+
+ Здесь закреплена ровно YAML-семантика (разбор файла, globs, поведение
+ guard'а и /me на trusted-header пути); DB-first, приоритет реестра и
+ эквивалентность scope employee↔pilot покрыты tests/test_role_single_source.py.
+ """
+ monkeypatch.setattr(auth_mod, "_registry_role", lambda username: None)
+
+
# ---------------------------------------------------------------------------
# Test app — использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py), а не копию.
# ---------------------------------------------------------------------------
diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py
new file mode 100644
index 00000000..c60e00a8
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_role_single_source.py
@@ -0,0 +1,188 @@
+"""#3316 — роль резолвится из ОДНОГО источника: реестр (БД) первый, roles.yaml — fallback.
+
+Проверяется значение роли, а не факт вызова механизма:
+ * имя из roles.yaml, заведённое в реестре сотрудником → роль `employee`
+ (эскалации в admin нет: ни IDOR по чужим оценкам, ни безлимитной квоты);
+ * сотрудник, которого в roles.yaml НЕТ → роль резолвится, ownership-check
+ пропускает его к СВОЕЙ оценке и держит на чужой (раньше был KeyError → 403);
+ * legacy-юзер (есть в YAML, в реестре строки нет) → роль ровно как раньше —
+ сверяется ВЕСЬ маппинг roles.yaml, а не один аккаунт;
+ * реестр недоступен → fallback на YAML (падение БД не выключает legacy-вход).
+
+FastAPI здесь не поднимается: резолвер — чистая функция от (реестр, YAML),
+реестр подменяется фейковой сессией.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from types import SimpleNamespace
+from typing import Any
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import pytest
+
+from app.core import auth
+from app.services import identity_store
+
+_MISSING = object()
+
+
+class _FakeSession:
+ """Отдаёт одну строку `SELECT role ...` — или ничего, если роли нет."""
+
+ def __init__(self, role: str | None) -> None:
+ self.role = role
+ self.sql: str | None = None
+ self.params: dict[str, Any] | None = None
+
+ def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
+ self.sql = str(sql)
+ self.params = params
+ row = None if self.role is None else SimpleNamespace(role=self.role)
+ return SimpleNamespace(fetchone=lambda: row)
+
+
+@pytest.fixture
+def registry(monkeypatch: pytest.MonkeyPatch) -> Callable[..., _FakeSession | None]:
+ """`registry(role)` — что реестр отвечает на запрос роли.
+
+ role=None → строки нет (legacy-юзер); role=_MISSING → реестр падает.
+ """
+
+ def install(role: str | None | object) -> _FakeSession | None:
+ if role is _MISSING:
+
+ @contextmanager
+ def broken_session() -> Iterator[Any]:
+ raise RuntimeError("registry down")
+ yield # pragma: no cover — нужен, чтобы функция была генератором
+
+ monkeypatch.setattr(identity_store, "identity_session", broken_session)
+ return None
+
+ session = _FakeSession(role) # type: ignore[arg-type]
+
+ @contextmanager
+ def fake_session() -> Iterator[_FakeSession]:
+ yield session
+
+ monkeypatch.setattr(identity_store, "identity_session", fake_session)
+ return session
+
+ return install
+
+
+def _yaml_users() -> dict[str, str]:
+ return dict(auth._load_roles_config()["users"])
+
+
+def _yaml_admin() -> str:
+ for username, role in _yaml_users().items():
+ if role == "admin":
+ return username
+ pytest.skip("в auth/roles.yaml нет ни одного admin — тест неприменим")
+
+
+def test_registry_employee_beats_yaml_admin(registry: Callable[..., Any]) -> None:
+ """Эскалация закрыта: имя YAML-админа + строка `employee` в реестре = employee."""
+ victim_name = _yaml_admin()
+ session = registry("employee")
+
+ assert auth.get_role(victim_name) == "employee"
+ # username едет bind-параметром, а не склейкой в SQL.
+ assert session.params == {"username": victim_name}
+ assert victim_name not in (session.sql or "")
+
+
+def test_employee_absent_from_yaml_resolves_and_owns_estimate(
+ registry: Callable[..., Any],
+) -> None:
+ """Сотрудник вне roles.yaml: роль есть, своя оценка читается, чужая — нет."""
+ name = "employee_not_in_yaml_3316"
+ assert name not in _yaml_users()
+ registry("employee")
+
+ from fastapi import HTTPException
+
+ from app.api.v1.trade_in import _assert_estimate_access
+
+ # Продуктовое поведение проверяется ПЕРВЫМ: до #3316 здесь прилетал 403
+ # («user not in roles config») на СОБСТВЕННУЮ оценку сотрудника.
+ _assert_estimate_access(name, name)
+
+ assert auth.get_role(name) == "employee"
+
+ with pytest.raises(HTTPException) as exc:
+ _assert_estimate_access("someone_else", name)
+ assert exc.value.status_code == 404
+
+
+def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> None:
+ """В реестре строки нет → роли ВСЕХ YAML-юзеров ровно те же, что и были."""
+ registry(None)
+ users = _yaml_users()
+ assert users, "roles.yaml без юзеров — сверять нечего"
+ assert {username: auth.get_role(username) for username in users} == users
+
+
+def _yaml_pilot() -> str:
+ for username, role in _yaml_users().items():
+ if role == "pilot":
+ return username
+ pytest.skip("в auth/roles.yaml нет ни одного pilot — тест неприменим")
+
+
+def test_prod_config_pilot_in_yaml_employee_in_registry(registry: Callable[..., Any]) -> None:
+ """Прод-конфигурация 12 из 13 аккаунтов: строка в реестре ЕСТЬ и роль там иная.
+
+ Реестр главнее (`employee`), а объём прав от этого не меняется: scope
+ DB-роли `employee` обязан совпадать с вчерашним YAML-scope роли `pilot`.
+ Списки сверяются целиком — дрейф ЛЮБОГО из двух ловится здесь, а не
+ тихой потерей/выдачей раздела в проде.
+ """
+ from app.services.auth_session import DB_ROLE_PATHS
+
+ registry("employee")
+ assert auth.get_role(_yaml_pilot()) == "employee"
+
+ pilot = auth._load_roles_config()["roles"]["pilot"]
+ allow, deny = DB_ROLE_PATHS["employee"]
+ assert sorted(allow) == sorted(pilot["paths"])
+ assert sorted(deny) == sorted(pilot["deny"] or [])
+
+
+def test_prod_config_pilot_in_yaml_manager_in_registry(registry: Callable[..., Any]) -> None:
+ """Конфигурация kopylov: YAML pilot + реестр manager → manager.
+
+ Лишних путей на tradein-периметре это не даёт: manager отличается от
+ employee ровно одним префиксом `/api/v1/team/**` (дашборд «Команды»,
+ ВНЕ `/trade-in/**`), а deny-списки совпадают.
+ """
+ from app.services.auth_session import DB_ROLE_PATHS
+
+ registry("manager")
+ assert auth.get_role(_yaml_pilot()) == "manager"
+
+ emp_allow, emp_deny = DB_ROLE_PATHS["employee"]
+ mgr_allow, mgr_deny = DB_ROLE_PATHS["manager"]
+ extra = set(mgr_allow) - set(emp_allow)
+ assert extra == {"/api/v1/team/**"}
+ assert not any(p.startswith("/trade-in") for p in extra)
+ assert sorted(mgr_deny) == sorted(emp_deny)
+
+
+def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None:
+ """Реестр недоступен → legacy-вход продолжает работать по YAML."""
+ registry(_MISSING)
+ assert auth.get_role(_yaml_admin()) == "admin"
+
+
+def test_yaml_role_is_yaml_only(registry: Callable[..., Any]) -> None:
+ """Предикат гварда create_employee смотрит ИМЕННО в YAML, мимо реестра."""
+ registry("employee")
+ assert auth.yaml_role(_yaml_admin()) == "admin"
+ assert auth.yaml_role("employee_not_in_yaml_3316") is None
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
index 3d03e999..e46e5232 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
@@ -63,7 +63,18 @@ describe("describeCoverage", () => {
it("когда дата известна у всех — доля не упоминается вовсе", () => {
// «дата известна у 30 из 30» — шум: доля есть, но она ничего не сообщает.
const [, ageTile] = describeCoverage(probe({ n_listings: 30, n_with_age: 30 })).tiles;
- expect(ageTile.label).toBe("столько в среднем висит объявление");
+ expect(ageTile.label).toBe("столько и дольше висит каждое второе объявление");
+ });
+
+ it("возраст подписан как медиана — «в среднем» под percentile_cont(0.5) неправда", () => {
+ // Бэкенд считает `percentile_cont(0.5)` (trade_in.py::_coverage_probe).
+ // На возрасте объявлений распределение скошено вправо: среднее заметно
+ // больше медианы, и подпись «в среднем» называла бы другую величину.
+ for (const over of [{ n_with_age: 6 }, { n_with_age: 34 }]) {
+ const [, ageTile] = describeCoverage(probe(over)).tiles;
+ expect(ageTile.label).not.toMatch(/в среднем/i);
+ expect(ageTile.label).toMatch(/каждое второе|половин|медиан/i);
+ }
});
it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => {
@@ -142,9 +153,49 @@ describe("describeCityExpectation", () => {
const said = describeCityExpectation("Ревда");
expect(said?.text).toContain("проверках");
expect(said?.text).not.toMatch(/точност/i);
- // Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст
- // утверждает больше, чем измерено.
- expect(said?.text).toContain("84");
+ });
+
+ it("остаток разложен: где считаем с широким разбросом, а где не считаем вовсе", () => {
+ // Прежний текст называл остаток одним числом (100 − уверенные) и обещал
+ // расчёт на всём нём. Но пустые пробы сидят внутри этого остатка, а
+ // `describeCoverage` отвечает на них «рядом не продаётся ничего похожего»
+ // и платный шаг не предлагает. Числа берём из замера, а не из строки:
+ // подстрокой «3» нашлось бы и внутри «83».
+ for (const measure of CITY_COVERAGE) {
+ const thin = 100 - measure.confidentPct - measure.emptyPct;
+ const said = describeCityExpectation(measure.city);
+ const nums = (said?.text.match(/\d+/g) ?? []).map(Number);
+
+ expect(measure.emptyPct, `${measure.city}: нечего разделять`).toBeGreaterThan(0);
+ expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct);
+ expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin);
+ expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct);
+ // Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны
+ // оставлять место третьей доле, иначе рендер печатает «Ещё в 0».
+ expect(
+ measure.confidentPct + measure.emptyPct,
+ `${measure.city}: средней доле не осталось места`,
+ ).toBeLessThan(100);
+ // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
+ // расчёт там, где следующий же экран в нём откажет.
+ expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
+ /считать не станем/,
+ );
+ }
+ });
+
+ it("когда средней доли не осталось — предложения про неё нет вовсе", () => {
+ // Перезамер вида 85 уверенных + 15 пустых законен, и «Ещё в 0 данных
+ // меньше» на нём было бы сообщением о доле, которой не существует.
+ const said = describeCityExpectation("Ревда", {
+ city: "Ревда",
+ confidentPct: 85,
+ emptyPct: 15,
+ sampleN: 100,
+ });
+ expect(said?.text).not.toMatch(/Ещё в 0|в 0 /);
+ expect((said?.text.match(/\d+/g) ?? []).map(Number)).toEqual([85, 100, 15]);
+ expect(said?.text).toMatch(/считать не станем/);
});
it("неизвестный город не выдумывает величину", () => {
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts
new file mode 100644
index 00000000..882d82f4
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts
@@ -0,0 +1,177 @@
+/**
+ * Реальные названия площадок-источников не должны попадать в видимый
+ * клиентский UI (лендинг «Мера» + компоненты отчёта trade-in).
+ *
+ * Канон: `src/lib/source-registry.ts` — `publicLabel`/`sourcePublicLabel()`.
+ * Одна площадка = один и тот же нейтральный номер («Источник N»), Росреестр —
+ * исключение (не листинговая площадка, юр-риска в названии нет). Причина —
+ * тот же юр-риск, что закрывал #2211/#3264-scope: конкретное название
+ * площадки в клиентском отчёте — коммерческая информация, которую посетитель
+ * видеть не должен.
+ *
+ * ОТЛИЧИЕ от соседнего `public-copy-no-infra.test.ts`: тот гейт специально
+ * сканирует ВЕСЬ текст файла, включая комментарии («на боевой базе» в
+ * комментарии так же вредно, как в строке — комментарий может стать
+ * заготовкой будущей копии). Здесь наоборот: название площадки в комментарии
+ * ЗАКОННО и нужно разработчику — оно объясняет происхождение данных
+ * (`// avito_imv — реальная Avito IMV-оценка`), а `sourceLabel`/`SOURCES`
+ * в `source-registry.ts` обязаны хранить реальные лейблы для админки. Поэтому
+ * перед проверкой из текста вырезаются `//`- и `/* *\/`-комментарии, и гейт
+ * смотрит только на то, что реально дойдёт до посетителя в виде строки.
+ *
+ * Ограничение вырезки комментариев: `//` не режется, если ему предшествует
+ * `:` (т.е. это `https://`/`http://` внутри строки, а не начало комментария).
+ * Полноценный JS/TS-парсер тут избыточен — в скоупе сканирования нет `//`
+ * иначе как в URL или в начале комментария (проверено вручную).
+ *
+ * Второе отличие от инфра-гейта: тот matches подстрокой (`includes`) —
+ * инфраструктурные имена никогда не встречаются внутри TS-идентификаторов.
+ * Названия площадок встречаются: тип `CianValuationSummary`, хук
+ * `useEstimateCianPriceChanges`, переменная `cianId` — это ЗАКОННЫЙ код
+ * (правило задачи: имена переменных/типов/хуков не трогаем, меняется только
+ * то, что видит человек). Поэтому здесь матчинг по границе слова
+ * (Unicode-aware `\p{L}`/`\p{N}`, не ASCII `\b`, иначе он вообще не сработает
+ * на кириллице), а не голый `includes`.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const MERA_PUBLIC_ROOT = join(__dirname, "..");
+const TRADE_IN_COMPONENTS_ROOT = join(__dirname, "../../../components/trade-in");
+
+/**
+ * Реальные названия площадок. Список ЯВНЫЙ (не эвристика по паттерну) —
+ * см. докстринг соседнего инфра-гейта про тот же выбор: эвристика ловила бы
+ * законные вещи (напр. домены listing-ссылок avito.ru/cian.ru, которые
+ * остаются в href по правилу «URL объявлений не трогаем»).
+ *
+ * Матчинг НАМЕРЕННО регистрозависимый: строчные `"avito"` / `"cian"` /
+ * `"yandex"` — это id источников (`keys: ["avito", "avito_imv"]`,
+ * `sourcePublicLabel("cian")`), и они законны. Поэтому капс-варианты
+ * (плитки, сноски статей — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» жил в статье и
+ * проходил гейт) и словоформы («Домклике», «Яндексу») перечислены явно.
+ * Яндекс/Yandex здесь и как площадка (Я.Недвижимость), и как геокодер
+ * («Yandex / Nominatim» в подсказке адреса): посетителю не нужен ни тот,
+ * ни другой.
+ */
+const BANNED = [
+ "Avito",
+ "AVITO",
+ "Авито",
+ "АВИТО",
+ "Циан",
+ "ЦИАН",
+ "Циана",
+ "Циане",
+ "Cian",
+ "CIAN",
+ "Я.Недвижимость",
+ "Я.НЕДВИЖИМОСТЬ",
+ "ДомКлик",
+ "Домклик",
+ "ДОМКЛИК",
+ "Домклике",
+ "Домклика",
+ "Domclick",
+ "DOMCLICK",
+ "Яндекс",
+ "ЯНДЕКС",
+ "Яндексу",
+ "Яндекса",
+ "Яндекс.Недвижимость",
+ "Yandex",
+ "YANDEX",
+ "Этажи",
+ "ЭТАЖИ",
+ "Avito Оценка",
+ "Я.Оценка",
+ "Циан Оценка",
+ // Бренд-термины оценочной модели Avito: «IMV» / «Information Market Value».
+ // Идентификаторы (IMVBenchmark, imvPrice) границей слова не задеваются.
+ "IMV",
+ "Information Market Value",
+];
+
+/**
+ * Файлы копии в скоупе: лендинг «Мера» + компоненты отчёта trade-in.
+ * `src/app/scrapers/**` и `src/components/scrapers/**` (admin-only, отдельные
+ * top-level каталоги) вне обоих корней сканирования — их сюда не заносит.
+ */
+function copySources(dir: string, acc: string[] = []): string[] {
+ for (const name of readdirSync(dir)) {
+ if (name === "__tests__" || name === "node_modules") continue;
+ const full = join(dir, name);
+ if (statSync(full).isDirectory()) {
+ copySources(full, acc);
+ } else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+/** Вырезает `/* *\/`- и `//`-комментарии; `//` внутри `https://` не режет. */
+function stripComments(text: string): string {
+ // Newlines внутри блочного комментария сохраняем (заменяем на "\n" по числу
+ // строк), иначе после вырезки съедутся номера строк в отчёте о находке.
+ const noBlock = text.replace(/\/\*[\s\S]*?\*\//g, (m) =>
+ "\n".repeat((m.match(/\n/g) ?? []).length),
+ );
+ return noBlock
+ .split("\n")
+ .map((line) => {
+ // Ищем `//`, которому НЕ предшествует `:` (т.е. не часть `http(s)://`).
+ const idx = line.search(/(? {
+ const files = [
+ ...copySources(MERA_PUBLIC_ROOT),
+ ...copySources(TRADE_IN_COMPONENTS_ROOT),
+ ];
+
+ it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => {
+ expect(files.length).toBeGreaterThan(20);
+ expect(files.some((f) => f.endsWith("HeroSummary.tsx"))).toBe(true);
+ expect(files.some((f) => f.endsWith("IMVBenchmark.tsx"))).toBe(true);
+ });
+
+ it.each(BANNED)("нигде в видимой копии не встречается «%s»", (needle) => {
+ const hits: string[] = [];
+ for (const file of files) {
+ const raw = readFileSync(file, "utf8");
+ const text = stripComments(raw);
+ text.split("\n").forEach((line, i) => {
+ if (hasWholeWord(line, needle)) {
+ hits.push(`${file}:${i + 1}`);
+ }
+ });
+ }
+ expect(
+ hits,
+ `«${needle}» — реальное название площадки в видимой клиентской копии. ` +
+ `Используй sourcePublicLabel() из src/lib/source-registry.ts вместо ` +
+ `хардкода: посетитель отчёта не должен видеть, какие площадки реально ` +
+ `участвовали в оценке (юр-риск, #3264-scope). В комментариях это имя ` +
+ `законно — гейт сюда не смотрит.`,
+ ).toEqual([]);
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
new file mode 100644
index 00000000..8143d250
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
@@ -0,0 +1,85 @@
+/**
+ * Какие сделки попадают в игру «Угадай цену».
+ *
+ * Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому
+ * первые три её строки на проде оказывались тремя студиями с одной улицы: игра
+ * трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора
+ * — какие именно строки взяты, — а не факт вызова функции.
+ */
+import { describe, expect, it } from "vitest";
+
+import { pickVariedDeals } from "../_components/v3/deal-view";
+import type { ShowcaseDeal } from "../public-api";
+
+const deal = (rooms: number, street: string | null, area_m2 = 40): ShowcaseDeal =>
+ ({
+ district: "Кировский",
+ rooms,
+ area_m2,
+ floor: 3,
+ total_floors: 9,
+ deal_quarter: "II квартал 2026",
+ predicted_rub: 5_000_000,
+ fact_rub: 5_000_000,
+ err_pct: 0,
+ n_analogs: 8,
+ note: "",
+ street_name: street,
+ street_scheme: null,
+ lat: null,
+ lon: null,
+ }) as ShowcaseDeal;
+
+/** Порядок — как на витрине: студии одной улицы стоят первыми. */
+const showcase: readonly ShowcaseDeal[] = [
+ deal(0, "улица Ленина", 25),
+ deal(0, "улица Ленина", 27),
+ deal(0, "улица Ленина", 29),
+ deal(2, "улица Ленина", 62),
+ deal(1, "улица Мира", 38),
+ deal(2, "улица Щорса", 58),
+];
+
+describe("pickVariedDeals", () => {
+ it("три раунда — три разные комнатности и три разные улицы", () => {
+ const picked = pickVariedDeals(showcase, 3);
+ expect(picked).toHaveLength(3);
+ expect(picked.map((d) => d.rooms)).toEqual([0, 1, 2]);
+ expect(picked.map((d) => d.street_name)).toEqual([
+ "улица Ленина",
+ "улица Мира",
+ "улица Щорса",
+ ]);
+ // Прежний slice(0, 3) взял бы три студии с Ленина — вот они, и их нет.
+ expect(picked.map((d) => d.area_m2)).toEqual([25, 38, 58]);
+ });
+
+ it("когда разнообразия нет — берём сколько есть, а не показываем меньше раундов", () => {
+ // Игра не рендерится, пока раундов меньше трёх. Отдать два «ради чистоты
+ // выбора» значило бы погасить секцию из-за однородной витрины.
+ const same = [deal(0, "улица Ленина", 25), deal(0, "улица Ленина", 27), deal(0, "улица Ленина", 29)];
+ expect(pickVariedDeals(same, 3)).toHaveLength(3);
+ });
+
+ it("строки без улицы не считаются повтором улицы", () => {
+ // `street_name` пуст примерно у 8% строк — такая строка про улицу молчит.
+ const noStreet = [deal(1, null, 38), deal(1, "улица Мира", 40), deal(2, null, 58)];
+ expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]);
+ });
+
+ it("пустая строка в street_name — не ключ улицы, а её отсутствие", () => {
+ // `!== null` считал "" настоящим названием, и вторая такая строка
+ // вылетала как «повтор улицы» — повтора здесь нет, есть два молчания.
+ // Третья строка — та, которую при этом брали ВМЕСТО второй.
+ const blank = [deal(1, "", 38), deal(2, "", 58), deal(2, "улица Мира", 60)];
+ expect(pickVariedDeals(blank, 2).map((d) => d.area_m2)).toEqual([38, 58]);
+ });
+
+ it("выбор детерминирован — страница рендерится на сервере", () => {
+ // Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
+ // поэтому никакого случайного выбора здесь быть не может.
+ const first = pickVariedDeals(showcase, 3);
+ const second = pickVariedDeals(showcase, 3);
+ expect(first.map((d) => d.area_m2)).toEqual(second.map((d) => d.area_m2));
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
index 1d700573..69132cb7 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
@@ -66,7 +66,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
tiles.push({
key: "share",
value: share.text,
- title: "столько объявлений на Домклике снижали цену",
+ title: "столько объявлений на одной из площадок снижали цену",
text: [share.note, share.sample].filter(Boolean).join(" · "),
});
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx
index ea0a9a75..2aebb587 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx
@@ -51,9 +51,12 @@ export function SourcesV3() {
{group.title}
{group.items.map((item) => (
-
-
- {item}
+
+
+ {item.label}
))}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx
index 2642138b..ae38d5b1 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx
@@ -63,7 +63,7 @@ export function TwoPathsV3() {
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
не хочется вести показы и торг — передайте продажу нашим
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
- по цене из вашего отчёта, стоимость фиксированная и известна
+ по цене из вашего отчёта, стоимость услуг фиксированная и известна
заранее. Ни к чему не обязывает — просто узнайте условия.