From 0ed934ea09984f2cd0fd0adfb92fa8ffc33c6ccd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:44:38 +0500 Subject: [PATCH 01/13] =?UTF-8?q?fix(scraper-kit):=20=D1=87=D0=B5=D0=BA?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=20avito=5Fcity=5Fsweep=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B6=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20(#3319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0 из 67 прогонов за 60 дней имели done_buckets в counters: точку писала одна строка внутри цикла якорей, а каждый выход (mark_done — включая ранний #1950 «SERP собран, detail заблокирован», — mark_banned, mark_failed) отдавал голый counters.to_dict(). Точка держалась только на jsonb-мерже в runs.py, то есть на свойстве чужого модуля, которого этот файл не проверяет. - payload любого выхода собирается одной функцией _ckpt() — done_buckets несут все 14 записей, а не одна; - якорь, умерший по таймауту, больше не считается пройденным (тот же инвариант, что у generic-except): SERP мог успеть, detail нет, и резюм пропускал такой якорь навсегда при штатно завершившемся прогоне; - SIGTERM-дрейн помечается counters.interrupted=1 и участвует в резюме. Статус остаётся 'done' — ни один читатель статуса не меняется; метка та же, что у rosreestr_dkp-дрейна. 'done' в _RESUME_STATUSES НЕ добавлен: чистый полный обход резюмить нечего. Дрейн перед IMV-фазой помечен отдельно (imv_phase_drained) — якоря там пройдены все, подхват собрал бы ноль. --- .../tests/test_3319_citysweep_checkpoint.py | 214 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 56 +++-- .../scraper_kit/orchestration/scheduler.py | 9 +- 3 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py 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..6ae05a73 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py @@ -0,0 +1,214 @@ +"""Чекпоинт avito_city_sweep доживает до финализатора (#3319). + +Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в +counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930 +(подхват точки) существуют оба, но между ними нет ни одного прогона: точку +писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона +(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», — +mark_banned, mark_failed) отдавал голый counters.to_dict() без неё. + +Три инварианта, ради которых тест: + 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" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 71599c87..2cbacf9c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -1183,6 +1183,18 @@ async def run_avito_city_sweep( ) _done_anchors: set[str] = set(_skip_anchors) + def _ckpt(**extra: Any) -> dict[str, Any]: + """Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319). + + До этого точку писала ровно одна строка внутри цикла якорей, а все + финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход + #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`. + Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве + ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся + раньше первой записи (или писатель без мержа), терял её молча. + """ + return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra} + _loc = get_city_location(city_slug) # #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города # он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия). @@ -1287,7 +1299,7 @@ async def run_avito_city_sweep( len(_anchors), name, ) - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) return counters elif shutdown_requested(): # Кооперативный SIGTERM-drain (#1182 Phase 3a): останавливаемся @@ -1301,8 +1313,13 @@ async def run_avito_city_sweep( len(_anchors), name, ) - runs.update_heartbeat(db, run_id, counters.to_dict()) - runs.mark_done(db, run_id, counters.to_dict()) + # #3319: 'done' с counters.interrupted=1 — НЕ полный обход + # (та же метка, что у rosreestr_dkp-дрейна). Без неё оборванный + # деплоем прогон неотличим от честно обошедшего все якоря: + # статус тот же, счётчики частичные, а резюм его не берёт. + # Читатели статуса не трогаем — 'done' остаётся 'done'. + runs.update_heartbeat(db, run_id, _ckpt(interrupted=1)) + runs.mark_done(db, run_id, _ckpt(interrupted=1)) return counters logger.info( @@ -1762,6 +1779,12 @@ async def run_avito_city_sweep( _avito_anchor_timeout, ) counters.errors_count += 1 + # #3319: тот же инвариант, что у generic-except ниже. Якорь, + # умерший по таймауту, ПРОЙДЕН НЕ БЫЛ: SERP мог успеть, а + # detail/houses — нет, и какая именно часть осталась несобранной, + # здесь неизвестно. Считать его пройденным значит, что резюм + # пропустит его навсегда — молча, при штатно завершившемся прогоне. + _anchor_ok = False except (AvitoBlockedError, AvitoRateLimitedError) as e: logger.error( "city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s", @@ -1773,7 +1796,7 @@ async def run_avito_city_sweep( ) counters.errors_count += 1 counters.anchors_done = idx - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) # #1950: если SERP уже собрал лоты и заблокировало только detail/houses, # ставим 'done' (не 'banned') — partial intake сохранён. # За флагом avito_serp_ok_not_banned (default True). @@ -1794,14 +1817,14 @@ async def run_avito_city_sweep( runs.mark_done( db, run_id, - {**counters.to_dict(), "enrichment_abort_note": _note}, # type: ignore[arg-type] + _ckpt(enrichment_abort_note=_note), # type: ignore[arg-type] ) else: runs.mark_banned( db, run_id, str(e), - counters.to_dict(), + _ckpt(), ban_kind=ban_kind_of_exception(e), ) return counters @@ -1819,9 +1842,7 @@ async def run_avito_city_sweep( # завершится штатно. Тот же инвариант, что у combo в yandex-свипе. if _anchor_ok: _done_anchors.add(name) - runs.update_heartbeat( - db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)} - ) + runs.update_heartbeat(db, run_id, _ckpt()) # ── IMV-фаза: финальный обход тронутых домов ────────── if enrich_imv and all_touched_house_ids: @@ -1831,7 +1852,7 @@ async def run_avito_city_sweep( run_id, len(all_touched_house_ids), ) - runs.mark_done(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, _ckpt()) return counters elif shutdown_requested(): # SIGTERM-drain до IMV-фазы: финализируем без дорогой IMV-оценки. @@ -1841,7 +1862,10 @@ async def run_avito_city_sweep( run_id, len(all_touched_house_ids), ) - runs.mark_done(db, run_id, counters.to_dict()) + # #3319: тоже дрейн, но якоря пройдены ВСЕ — резюмить нечего + # (пропустил бы весь список и собрал ноль), поэтому метка + # диагностическая, а не резюм-флаг `interrupted`. + runs.mark_done(db, run_id, _ckpt(imv_phase_drained=1)) return counters logger.info( @@ -1855,7 +1879,7 @@ async def run_avito_city_sweep( # без update_heartbeat, и reap_zombies помечает живой run # 'zombie' → последующий mark_done становится no-op (дубль-sweep). def _imv_heartbeat() -> None: - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) imv_result = await enrichment.process_houses_imv_batch( db, @@ -1867,7 +1891,7 @@ async def run_avito_city_sweep( counters.imv_enriched += imv_result.saved counters.imv_failed += imv_result.errors counters.errors_count += imv_result.errors - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) logger.info( "city-sweep run_id=%d: IMV phase done — attempted=%d enriched=%d failed=%d", run_id, @@ -1892,9 +1916,9 @@ async def run_avito_city_sweep( db.rollback() except Exception: pass - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) - runs.mark_done(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, _ckpt()) logger.info( "city-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) " "houses=%d/%d detail=%d/%d imv=%d/%d errors=%d", @@ -1916,7 +1940,7 @@ async def run_avito_city_sweep( except Exception as exc: logger.exception("city-sweep run_id=%d: fatal error", run_id) - runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + runs.mark_failed(db, run_id, str(exc), _ckpt()) raise diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 0892d454..d49712c7 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -638,7 +638,14 @@ def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]: } _boot_reaped_zombie = row.prev_status == "zombie" and prev_counters.get("boot_reaped") is True - if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie: + # 'done' с counters.interrupted=1 — SIGTERM-drain (#3319): статус штатный, но обход + # оборван на границе корзины, часть дерева не собрана. Сам статус в _RESUME_STATUSES + # не добавлен НАМЕРЕННО: чистое 'done' — полный проход, резюмить у него нечего, а + # подхват такой точки означал бы, что источник больше никогда не обходится целиком. + # Метка — та же, что у rosreestr_dkp-дрейна (app/services/scheduler.py), поэтому ни + # один читатель статуса не меняется. + _drained_done = row.prev_status == "done" and bool(prev_counters.get("interrupted")) + if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie and not _drained_done: verdict["resume_reason"] = f"status_{row.prev_status}" elif not row.same_params: verdict["resume_reason"] = "params_changed" From 4feb61c006630394645824f697a80402a30372fa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:49:27 +0500 Subject: [PATCH 02/13] =?UTF-8?q?fix(tradein):=20=D1=80=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BB=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D0=BE=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0,=20ro?= =?UTF-8?q?les.yaml=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20f?= =?UTF-8?q?allback=20(#3316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`), а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не сверял. Дефект двусторонний: * вверх: менеджер заводил сотрудника с именем, которое уже числится в roles.yaml админом (проверялись лишь regex и уникальность в БД) — на входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки (admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту; * вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на СОБСТВЕННУЮ оценку. Источник теперь один и лечится один раз — в `app.core.auth.get_role`: реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым, roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход. Вызывающие (rbac, trade_in, team, account_quota) не менялись. Сопутствующее, чтобы поведение существующих аккаунтов не поехало: * rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS), иначе employee/manager на legacy-пути получил бы 403 на всё; * get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS; * право на персональный `unlimited` осталось за roles.yaml (account_quota + _batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую; * `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список «Команды» снова стал бы N+1. Defense-in-depth: create_employee отдаёт 409 на username, за которым в roles.yaml числится не-employee роль. --- tradein-mvp/backend/app/api/v1/team.py | 44 +++++- tradein-mvp/backend/app/core/auth.py | 85 ++++++++++- tradein-mvp/backend/app/core/rbac.py | 8 +- .../backend/app/services/account_quota.py | 13 +- .../backend/tests/test_role_single_source.py | 142 ++++++++++++++++++ 5 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_role_single_source.py 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..0906875b 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,65 @@ 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-вход. + + Имя таблицы берётся из фиксированного словаря `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 +281,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/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py new file mode 100644 index 00000000..da358dcd --- /dev/null +++ b/tradein-mvp/backend/tests/test_role_single_source.py @@ -0,0 +1,142 @@ +"""#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 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 From 41f21c4969c63c8125267f45c441c8c98f713ce2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:53:02 +0500 Subject: [PATCH 03/13] =?UTF-8?q?docs(scraper-kit):=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20#3319=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D1=82=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B5=D1=80=D1=8F=D0=BB=D0=BE=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=BF=D0=BE=D0=B8=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью опровергло формулировку «точку терял каждый финализатор»: все четыре писателя в runs.py мержат jsonb (`counters || :counters`), записанный ключ переживал mark_done/mark_banned/mark_failed. Правка закрывает выходы РАНЬШЕ первого end-of-anchor heartbeat (cancel/дрейн на первом якоре, ранний done #1950 на якоре №1) — комментарий и докстринг переписаны на это. Замер «0 из 67 за 60 дней» назван тем, чем он является: запись появилась 26.08.2026 (#3074) при такте avito 7 суток, выборка почти вся из эры без механизма. Плюс тест на прогон без ключей (эра до #3074) — метка дрейна не меняет вердикт «нечего подхватывать». --- .../tests/test_3319_citysweep_checkpoint.py | 23 ++++++++++++++----- .../src/scraper_kit/orchestration/pipeline.py | 18 ++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py index 6ae05a73..a238119b 100644 --- a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py +++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py @@ -1,11 +1,15 @@ """Чекпоинт avito_city_sweep доживает до финализатора (#3319). -Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в -counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930 -(подхват точки) существуют оба, но между ними нет ни одного прогона: точку -писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона -(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», — -mark_banned, mark_failed) отдавал голый counters.to_dict() без неё. +Точку писала ровно одна строка — 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 — иначе точка существует только в логе. @@ -212,3 +216,10 @@ def test_drained_done_is_resumable_but_clean_done_is_not() -> None: 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/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 2cbacf9c..654927ef 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -1186,12 +1186,18 @@ async def run_avito_city_sweep( def _ckpt(**extra: Any) -> dict[str, Any]: """Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319). - До этого точку писала ровно одна строка внутри цикла якорей, а все - финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход - #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`. - Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве - ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся - раньше первой записи (или писатель без мержа), терял её молча. + До этого точку писала ровно одна строка — end-of-anchor heartbeat в конце + итерации цикла. Финализаторы её НЕ стирали: все четыре писателя в runs.py + мержат jsonb (`counters || :counters`), уже записанный ключ переживал и + mark_done, и mark_banned, и mark_failed. Закрывается другая дыра — выходы, + случившиеся РАНЬШЕ первой такой записи: cancel/SIGTERM-дрейн на границе + первого якоря и ранний done #1950 («SERP собран, detail заблокирован») на + якоре №1. Именно им и кончается типичный прод-прогон, у которого + `anchors_done: 1` из 5. + + Замер «0 из 67 прогонов за 60 дней несут done_buckets» сам по себе этого НЕ + доказывает: строка записи появилась только 26.08.2026 (#3074), а такт avito + — 7 суток, так что выборка почти целиком из эры, где механизма не было. """ return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra} From cde95aa9f6ebde0886be0171f2ed832a3cee2746 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:59:14 +0500 Subject: [PATCH 04/13] =?UTF-8?q?test(tradein):=20=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B5=20#3316=20+=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D1=80=D0=BE=D0=BC=D0=B8=D1=81=D1=81=20=D1=84=D0=BE?= =?UTF-8?q?=D0=BB=D0=B1=D1=8D=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой конфигурации: * YAML pilot + реестр employee → employee, и scope не поехал: allow/deny DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо потерянный/выданный раздел в проде; * YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне /trade-in/**), deny-списки совпадают. Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты 409-гвардом create_employee); появится коллизия — ветку менять на fail-closed. --- tradein-mvp/backend/app/core/auth.py | 9 ++++ .../backend/tests/test_role_single_source.py | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py index 0906875b..512b924a 100644 --- a/tradein-mvp/backend/app/core/auth.py +++ b/tradein-mvp/backend/app/core/auth.py @@ -180,6 +180,15 @@ def _registry_role(username: str) -> str | None: пустая, либо реестр вообще недоступен. Во всех трёх случаях решение остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход. + ⚠️ Осознанный компромисс (#3316 review): последняя ветка — недоступный + реестр — на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно + то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий + имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в + `team.create_employee`. Если коллизия всё же появится (ручной INSERT в + реестр, расширение roles.yaml) — сбой БД станет окном эскалации, и тогда + эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не + дописывать проверки у вызывающих. + Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение едет bind-параметром: снаружи в SQL не попадает ничего. """ diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py index da358dcd..c60e00a8 100644 --- a/tradein-mvp/backend/tests/test_role_single_source.py +++ b/tradein-mvp/backend/tests/test_role_single_source.py @@ -129,6 +129,52 @@ def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> Non 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) From 18e0f1d9991342b39ae37d243c6ba965dd7c57e4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 16:46:44 +0500 Subject: [PATCH 05/13] =?UTF-8?q?test(tradein):=20=D0=B8=D0=B7=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20test=5Frbac.py=20?= =?UTF-8?q?=D0=BE=D1=82=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0=20(#3316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI-красное на голове ветки: 4 теста в tests/test_rbac.py ждали YAML-роль (kopylov=pilot, user1=pilot), а в CI-базе реестр засеян миграцией 193 (kopylov=manager, user*=employee) — DB-first резолвер честно отдавал роль из БД. Локально те же тесты были зелёными ровно потому, что БД нет и работал YAML-fallback: результат файла зависел от ОКРУЖЕНИЯ, а такой тест не проверяет ничего. Чинится не подгонкой чисел в ассертах, а изоляцией: файл проверяет ИМЕННО legacy-путь roles.yaml (разбор файла, globs, guard и /me на trusted-header), и теперь заявляет это явно — autouse-фикстура `_legacy_yaml_only` глушит реестр (`_registry_role` → None). Ассерты на YAML-роли после этого законны в любом окружении. Приоритет реестра, эквивалентность scope employee↔pilot и конфигурация kopylov (DB manager + YAML pilot) покрыты отдельно — tests/test_role_single_source.py. Проверено обоими способами: полный `pytest tests` без сида и он же с плагином-имитацией засеянного реестра (подменяется тот же шов, что и в проде, `identity_store.identity_session`) — 5290 passed, 35 skipped в обоих. --- tradein-mvp/backend/tests/test_rbac.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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), а не копию. # --------------------------------------------------------------------------- From e2e7e17f953923bebe7624ea4726514f1ff4ebbe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:35:48 +0500 Subject: [PATCH 06/13] =?UTF-8?q?fix(domclick):=20=D0=BA=D0=B0=D0=B6=D0=B4?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D0=B5=D1=82=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=20(#3?= =?UTF-8?q?332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода, поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1 (прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев (#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed. Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана, но строки уже нет — попытка была, исхода не было. Closes #3332 --- .../app/tasks/domclick_detail_backfill.py | 22 +++ .../test_3332_domclick_counter_identity.py | 179 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py 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/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 не должна начислять исход дважды" + ) From c093212eaeaff8d2aadc3d4d4bdcecf2fca41800 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:37:13 +0500 Subject: [PATCH 07/13] =?UTF-8?q?fix(mera-public):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=8B=20=D0=B2=20=D1=80=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=85=20=D0=B8=D0=B3=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Остаток «100 − уверенные» в подписи города был однородным только на словах: внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и на них — две наши же строки спорили друг с другом. Остаток разложен на долю «посчитаем, но разброс шире» и долю «считать не станем». Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а «в среднем» под скошенным распределением называет другую величину. Раунды игры брались первыми тремя строками витрины, а она отсортирована по полноте и свежести: на проде это давало три студии с одной улицы. Выбор жадный по комнатности и улице, детерминированный — страница серверная. --- .../__tests__/coverage-copy.test.ts | 40 +++++++++- .../__tests__/varied-rounds.test.ts | 77 +++++++++++++++++++ .../mera-public/_components/v3/deal-view.ts | 34 ++++++++ .../src/app/mera-public/coverage-copy.ts | 25 ++++-- .../frontend/src/app/mera-public/page.tsx | 6 +- 5 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts 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..57cb0f5f 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,30 @@ 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); + expect(measure.confidentPct + thin + measure.emptyPct).toBe(100); + // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает + // расчёт там, где следующий же экран в нём откажет. + expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch( + /считать не станем/, + ); + } }); it("неизвестный город не выдумывает величину", () => { 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..88aa458a --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts @@ -0,0 +1,77 @@ +/** + * Какие сделки попадают в игру «Угадай цену». + * + * Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому + * первые три её строки на проде оказывались тремя студиями с одной улицы: игра + * трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора + * — какие именно строки взяты, — а не факт вызова функции. + */ +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("выбор детерминирован — страница рендерится на сервере", () => { + // Разный набор на сервере и в браузере — это разъехавшаяся гидрация, + // поэтому никакого случайного выбора здесь быть не может. + 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/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index b8751743..0a5bdcda 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -130,6 +130,40 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound { }; } +/** + * Строки витрины для игры — РАЗНЫЕ по типу квартиры и по улице. + * + * `slice(0, n)` брал первые в порядке витрины, а он отсортирован по полноте и + * свежести (`_sort_key` в `landing_showcase_deals.py`), а не по разнообразию: + * на проде это давало три студии с одной улицы, и игра выглядела как одна и та + * же квартира трижды. Жадно берём первую строку, у которой И комнатность, И + * улица ещё не встречались; когда таких не осталось — добираем первыми + * попавшимися, потому что раундов должно быть столько, сколько просили. + * + * Порядок строго по входу: страница рендерится на сервере, и любой `random` + * здесь означал бы разный набор на сервере и в браузере. + */ +export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): ShowcaseDeal[] { + const picked: ShowcaseDeal[] = []; + const rooms = new Set(); + const streets = new Set(); + for (const deal of deals) { + if (picked.length >= n) break; + if (rooms.has(deal.rooms)) continue; + // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она + // про неё молчит, и держать её из-за этого вне игры не за что. + if (deal.street_name !== null && streets.has(deal.street_name)) continue; + picked.push(deal); + rooms.add(deal.rooms); + if (deal.street_name !== null) streets.add(deal.street_name); + } + for (const deal of deals) { + if (picked.length >= n) break; + if (!picked.includes(deal)) picked.push(deal); + } + return picked; +} + /** * Разброс расхождений ПОКАЗАННЫХ строк. * diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index 42246c37..ffa884a0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -17,7 +17,8 @@ * объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё * продаются, реальная экспозиция проданных всегда меньше. Написать под * цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и - * тестом запинена. И при `median === null` (бэкенд занулил её, когда + * тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на + * бэкенде), а не средняя — «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда * возраст известен меньше чем по пяти объявлениям) плитки просто нет — * ноль вместо неизвестного значения был бы худшей из ошибок. */ @@ -73,10 +74,14 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] { const partial = probe.n_with_age < probe.n_listings; tiles.push({ value: `${probe.median_listing_age_days} дн.`, - // Формулировка запинена тестом — см. шапку модуля. + // Формулировка запинена тестом — см. шапку модуля. «В среднем» отсюда + // убрано 02.09: бэкенд считает `percentile_cont(0.5)` + // (`trade_in.py::_coverage_probe`) — это МЕДИАНА, и на скошенном вправо + // возрасте объявлений она заметно меньше среднего. «Каждое второе» — + // то же самое утверждение словами, которые не надо расшифровывать. label: partial - ? `столько в среднем висит объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}` - : "столько в среднем висит объявление", + ? `столько и дольше висит каждое второе объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}` + : "столько и дольше висит каждое второе объявление", }); } @@ -103,13 +108,17 @@ export function describeCityExpectation(city: string): CityExpectation | null { const measure = CITY_COVERAGE.find((m) => m.city === city); if (!measure) return null; - const rest = 100 - measure.confidentPct; + // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где + // считать нечего. Пока он назывался одним числом, текст обещал расчёт и на + // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» — + // две наши же строки спорили друг с другом. Делим остаток надвое. + const thin = 100 - measure.confidentPct - measure.emptyPct; return { text: `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + - `из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` + - `будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` + - "не находится ни одной похожей квартиры.", + `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + + `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` + + "похожей квартиры — так и скажем, а считать не станем.", source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, }; } diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx index ace48006..f3a753d6 100644 --- a/tradein-mvp/frontend/src/app/mera-public/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; -import { toGameRound } from "./_components/v3/deal-view"; +import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; import { fetchLandingStats, fetchShowcase } from "./public-api"; import styles from "./landing.module.css"; @@ -81,7 +81,9 @@ const MIN_ROUNDS = 3; export default async function MeraPublicPage() { const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]); const deals = showcase?.deals ?? []; - const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound); + // Не первые три, а три РАЗНЫЕ — иначе игра предлагает угадать одну и ту же + // студию с одной улицы трижды (см. `pickVariedDeals`). + const rounds = pickVariedDeals(deals, MIN_ROUNDS).map(toGameRound); return ( <> From 177207223bc3ca62fb37f9fc67446004d250c5bd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:41:38 +0500 Subject: [PATCH 08/13] =?UTF-8?q?fix(caddy):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20internal-?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=20=D1=81=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B8=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80=D0=B0=20=D1=81=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3324, п.3: Caddy на gendsgn.ru инжектил X-Internal-Auth-Secret в каждый анонимный запрос /trade-in/api/* и /trade-in/* (хоп во фронт). Сравнение недостижимо по построению: backend читает этот заголовок только в legacy trusted-header ветке rbac.py, и только после непустого X-Authenticated-User, который тот же самый хоп строкой ниже удаляет. Второй потребитель секрета (вебхук GlitchTip) берёт его из query-параметра. Защита от подделки заголовков внутри gendesign_shared — проверка в rbac.py, она не тронута; инжект работал в обратную сторону, раздавая секрет наружу. #3324, п.1: meraocenka.ru раздавал анонимам постраничные чанки App Router чужих маршрутов (chunks/app/{admin,team,scrapers,...}/) — код закрытого контура с именами внутренних ручек. Матчер отсекает 404-ом «есть подкаталог, и он не mera-public»; лэндингу нужны только chunks/app/<файл>.js и chunks/app/mera-public/* (замер на проде). Карта чанков в webpack-*.js остаётся — имена файлов по-прежнему читаются, полное разделение = split build. Регресс-тесты в smoke-mera-perimeter.sh отличают 404 Caddy (пустое тело) от 404 Next и отдельно проверяют, что статика лэндинга жива. --- caddy/sites/apps.caddy | 70 ++++++++++++++++++++++++++++----- scripts/smoke-mera-perimeter.sh | 45 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 085146a4..83f6293e 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,33 @@ 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 его не поддерживает. + # + # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков + # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень + # маршрутов из неё по-прежнему вычитывается. Полное разделение — 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/* + } + respond @foreignRouteChunk 404 + reverse_proxy tradein-frontend:3000 { header_up -X-Authenticated-User } @@ -499,9 +546,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. From ef166eaf2191d399e22dc19482a32d1f350af936 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:43:56 +0500 Subject: [PATCH 09/13] =?UTF-8?q?fix(mera-public):=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D1=82=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B2=20coverage-=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B5=20=D1=80=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка «уверенные + средние + пустые = 100» была верна по определению средней доли и упасть не могла. Инвариант перенесён на сам замер: уверенные и пустые обязаны оставлять место третьей доле. Если её всё-таки не останется (перезамер 85 + 15), текст теперь двухчастный — «Ещё в 0 данных меньше» отрендериться не может. `street_name !== null` считал пустую строку названием улицы, и вторая такая строка вылетала из игры как «повтор». Проверка на пустоту. --- .../__tests__/coverage-copy.test.ts | 21 +++++++++++++- .../__tests__/varied-rounds.test.ts | 8 ++++++ .../mera-public/_components/v3/deal-view.ts | 8 ++++-- .../src/app/mera-public/coverage-copy.ts | 28 +++++++++++++++---- 4 files changed, 55 insertions(+), 10 deletions(-) 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 57cb0f5f..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 @@ -170,7 +170,12 @@ describe("describeCityExpectation", () => { expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct); expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin); expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct); - expect(measure.confidentPct + thin + measure.emptyPct).toBe(100); + // Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны + // оставлять место третьей доле, иначе рендер печатает «Ещё в 0». + expect( + measure.confidentPct + measure.emptyPct, + `${measure.city}: средней доле не осталось места`, + ).toBeLessThan(100); // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает // расчёт там, где следующий же экран в нём откажет. expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch( @@ -179,6 +184,20 @@ describe("describeCityExpectation", () => { } }); + 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("неизвестный город не выдумывает величину", () => { expect(describeCityExpectation("Москва")).toBeNull(); }); 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 index 88aa458a..8143d250 100644 --- 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 @@ -67,6 +67,14 @@ describe("pickVariedDeals", () => { 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("выбор детерминирован — страница рендерится на сервере", () => { // Разный набор на сервере и в браузере — это разъехавшаяся гидрация, // поэтому никакого случайного выбора здесь быть не может. diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index 0a5bdcda..5bbd2f0e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -151,11 +151,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show if (picked.length >= n) break; if (rooms.has(deal.rooms)) continue; // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она - // про неё молчит, и держать её из-за этого вне игры не за что. - if (deal.street_name !== null && streets.has(deal.street_name)) continue; + // про неё молчит, и держать её из-за этого вне игры не за что. Проверка + // именно на пустоту, а не на `!== null`: пустая строка — то же самое + // молчание, и второй такой строке нельзя вылетать как «повтору улицы». + if (deal.street_name && streets.has(deal.street_name)) continue; picked.push(deal); rooms.add(deal.rooms); - if (deal.street_name !== null) streets.add(deal.street_name); + if (deal.street_name) streets.add(deal.street_name); } for (const deal of deals) { if (picked.length >= n) break; diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index ffa884a0..442c0edb 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -24,6 +24,7 @@ */ import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts"; +import type { CityCoverageMeasure } from "./landing-facts"; import type { CoverageProbe } from "./public-api"; export interface CoverageTile { @@ -104,8 +105,13 @@ export interface CityExpectation { * что выбрасывать города из списка не за что. Разница между ними * количественная — её и показываем числом, а не отсутствием опции. */ -export function describeCityExpectation(city: string): CityExpectation | null { - const measure = CITY_COVERAGE.find((m) => m.city === city); +export function describeCityExpectation( + city: string, + // Замер вторым аргументом — чтобы ветку «средней доли не осталось» можно + // было проверить, не подделывая CITY_COVERAGE: сегодня такого города в + // замере нет, а появиться он может при первом же перезамере. + measure: CityCoverageMeasure | undefined = CITY_COVERAGE.find((m) => m.city === city), +): CityExpectation | null { if (!measure) return null; // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где @@ -113,12 +119,22 @@ export function describeCityExpectation(city: string): CityExpectation | null { // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» — // две наши же строки спорили друг с другом. Делим остаток надвое. const thin = 100 - measure.confidentPct - measure.emptyPct; + const head = + `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} ` + + "проверках из 100. "; + // Средней доли может не остаться вовсе (перезамер вида 85 уверенных + 15 + // пустых) — тогда предложение про неё выпадает целиком. «Ещё в 0 данных + // меньше» было бы не округлением, а сообщением о доле, которой нет. + const empty = thin > 0 ? measure.emptyPct : 100 - measure.confidentPct; + const tail = + `В оставшихся ${empty} рядом не находится ни одной похожей квартиры — ` + + "так и скажем, а считать не станем."; return { text: - `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + - `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + - `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` + - "похожей квартиры — так и скажем, а считать не станем.", + thin > 0 + ? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + + `ответе это будет написано. ${tail}` + : head + tail, source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, }; } From 5d9ff13daea8d3b47ca8fea805497e5fee58c99d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:47:28 +0500 Subject: [PATCH 10/13] =?UTF-8?q?fix(caddy):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=5Fnot-found=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3340: `chunks/app/_not-found/` — внутренняя страница Next, а не маршрут продукта; фильтр её глушил, и клиентская навигация по битому href падала в error-boundary вместо честного «не найдено». Закрытого кода она не несёт. Заодно в комментарии матчера явно зафиксировано, что `static/css/app/<маршрут>/*` оставлен без фильтра сознательно — тот же класс «имена, а не код», потолок уже задекларирован. --- caddy/sites/apps.caddy | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 83f6293e..8695088d 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -496,10 +496,17 @@ meraocenka.ru { # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» — # `not` вместо негативного lookahead, RE2 его не поддерживает. # + # `_not-found` в исключениях — это не маршрут продукта, а внутренняя + # страница Next (404 при клиентской навигации по битому href). Закрытого + # кода не несёт, а без исключения клиентский переход падал бы в + # error-boundary вместо честного «не найдено». + # # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень - # маршрутов из неё по-прежнему вычитывается. Полное разделение — split - # build (отдельный public-бандл), отдельная задача. + # маршрутов из неё по-прежнему вычитывается. По той же причине оставлены + # без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не + # код», и городить второй матчер ради него смысла нет. Полное разделение — + # split build (отдельный public-бандл), отдельная задача. # # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок # директив Caddy), поэтому вложенный матчер не зависит от сортировки @@ -507,7 +514,7 @@ meraocenka.ru { handle /trade-in/_next/static/* { @foreignRouteChunk { path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/ - not path /trade-in/_next/static/chunks/app/mera-public/* + not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/* } respond @foreignRouteChunk 404 From 5541aa9d376621278819599288bc255c1d2cb661 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 3 Sep 2026 09:50:41 +0300 Subject: [PATCH 11/13] =?UTF-8?q?fix(mera-public):=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D1=81=D0=B5=D0=B1=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BC,=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8-=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому. 1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`, REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят «расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика». Комментарии и докстринги не тронуты — посетитель их не видит. 2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как фиксированная цена квартиры. 3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон — `publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками), Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной; `sourceLabel()` с реальными именами живёт для админки. Backend: якорь confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки». Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм. Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и components/trade-in/** без комментариев, по Unicode-границе слова; список запретов регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так проходил первую версию гейта. Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок. Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`, `landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен ручной пересчёт или UPDATE трёх подстрок на проде. --- tradein-mvp/backend/app/services/estimator.py | 10 +- .../app/tasks/landing_showcase_deals.py | 6 +- .../test_2677_house_anchor_repair_basis.py | 2 +- .../public-copy-no-platform-names.test.ts | 173 ++++++++++++++++++ .../_components/v3/CostOfErrorV3.tsx | 2 +- .../mera-public/_components/v3/SourcesV3.tsx | 9 +- .../mera-public/_components/v3/TwoPathsV3.tsx | 2 +- .../mera-public/articles/articles-content.ts | 4 +- .../frontend/src/app/mera-public/content.ts | 26 ++- .../src/app/mera-public/landing-v3.module.css | 30 +++ tradein-mvp/frontend/src/app/page.tsx | 5 +- .../components/trade-in/CianValuationCard.tsx | 7 +- .../src/components/trade-in/DealsCard.tsx | 6 +- .../src/components/trade-in/EstimateForm.tsx | 2 +- .../src/components/trade-in/HeroSummary.tsx | 18 +- .../src/components/trade-in/IMVBenchmark.tsx | 25 +-- .../src/components/trade-in/ListingsCard.tsx | 6 +- .../src/components/trade-in/MapPicker.tsx | 4 +- .../components/trade-in/PriceHistoryChart.tsx | 25 ++- .../components/trade-in/SourcesProgress.tsx | 2 +- .../components/trade-in/StreetDealsCard.tsx | 5 +- .../src/components/trade-in/offer-rates.ts | 4 +- .../src/components/trade-in/saleShareUtils.ts | 21 +-- .../src/components/trade-in/trade-in.css | 27 ++- .../components/trade-in/v2/AnalyticsView.tsx | 26 +-- .../components/trade-in/v2/LocationDrawer.tsx | 7 +- .../components/trade-in/v2/ParamsPanel.tsx | 2 +- .../src/components/trade-in/v2/mappers.ts | 33 ++-- .../frontend/src/lib/source-registry.ts | 67 +++++-- 29 files changed, 430 insertions(+), 126 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 06763e6f..a3915707 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3347,7 +3347,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 = "оценочной модели площадки" _imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, @@ -3379,7 +3382,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/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/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/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..60c12c89 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts @@ -0,0 +1,173 @@ +/** + * Реальные названия площадок-источников не должны попадать в видимый + * клиентский 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 Оценка", + "Я.Оценка", + "Циан Оценка", +]; + +/** + * Файлы копии в скоупе: лендинг «Мера» + компоненты отчёта 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/_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) => ( -
  • -
  • +
  • ))}
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() { Отчёта достаточно, чтобы справиться самому. Но если нет времени или не хочется вести показы и торг — передайте продажу нашим специалистам: объявление, фото, показы, переговоры, сделка. Работаем - по цене из вашего отчёта, стоимость фиксированная и известна + по цене из вашего отчёта, стоимость услуг фиксированная и известна заранее. Ни к чему не обязывает — просто узнайте условия.

` в trade-in.css / `srcDot` в landing-v3.module.css. */ + readonly dot: string; +} + export interface SourceGroup { readonly title: string; - readonly items: readonly string[]; + readonly items: readonly SourceChip[]; readonly note: string; } @@ -330,22 +338,28 @@ export interface SourceGroup { * Осознанное сужение: в `SOURCES` у сделок есть ещё «Этажи» (kind: "deals"), * но на публичной странице говорим только про Росреестр — это продуктовое * решение владельца («сделки Росреестра + объявления площадок»), а не - * недосмотр. Лейбл берём через `sourceLabel`, чтобы не разъехаться с реестром. + * недосмотр. Лейбл — публичный (`publicLabel`, «Источник N»), а не название + * площадки (юр-риск, владелец продукта запретил показывать бренды посетителю + * лендинга); цвет точки (`dot`) при этом сохраняется — он опознаёт источник + * между секциями, как и раньше. */ export const SOURCE_GROUPS: readonly SourceGroup[] = [ { title: "Зарегистрированные сделки", - items: [sourceLabel("rosreestr")], + items: [{ label: sourcePublicLabel("rosreestr"), dot: sourceDot("rosreestr") }], note: "Цены, по которым квартиры действительно перешли к новым собственникам — по договорам купли-продажи. Сначала смотрим сделки по вашему дому, а если их мало — по ближайшему окружению.", }, { title: "Объявления о продаже", - items: LIVE_LISTING_SOURCES.map((s) => s.label), + items: LIVE_LISTING_SOURCES.map((s) => ({ label: s.publicLabel, dot: s.dot })), note: "Что просят прямо сейчас за похожие квартиры: площадь, этаж, тип дома, состояние.", }, { title: "Оценочные модели площадок", - items: SOURCES.filter((s) => s.kind === "valuation").map((s) => s.label), + items: SOURCES.filter((s) => s.kind === "valuation").map((s) => ({ + label: s.publicLabel, + dot: s.dot, + })), note: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.", }, ]; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index 8c6feab8..7ad86759 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -3019,6 +3019,36 @@ background: var(--b2c-accent); } +/* + * Цвет точки источника — тот же опознаватель, что в отчёте (src-dot.* в + * trade-in.css), но лендинг (`landing-v3.module.css`) — отдельный CSS-модуль + * без доступа к переменным из trade-in.css (разные скоупы). Значения ниже + * продублированы 1:1 из trade-in.css (--src-* рядом с viz-палитрой), чтобы + * цвет точки совпадал между лендингом и отчётом. Правишь там — правь здесь: + * связь держится этим комментарием, а не кодом. + * + * Сами цвета ≈ фирменные цвета площадок (решение 31.08.2026): название + * заменено на «Источник N», опознаватель — цвет, и он намеренно узнаваем. + */ +.srcChipDot--cian { + background: #0468ff; /* trade-in.css --src-cian */ +} +.srcChipDot--avito { + background: #00aaff; /* trade-in.css --src-avito */ +} +.srcChipDot--dom { + background: #21a038; /* trade-in.css --src-dom */ +} +.srcChipDot--yandex { + background: #ffcc00; /* trade-in.css --src-yandex */ +} +.srcChipDot--etagi { + background: #f15044; /* trade-in.css --src-etagi */ +} +.srcChipDot--rosreestr { + background: var(--b2c-ink); /* trade-in.css --fg */ +} + .srcNote { margin: 0; font-size: 14px; diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 125466c3..834247d2 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -40,7 +40,8 @@ import { LIVE_LISTING_SOURCES } from "@/lib/source-registry"; // #2211 находка 7: список источников объявлений выводится из source-registry // (источник n1 полностью выключен #2204 → его тут больше нет), а не хардкодится числом. -const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.label).join(", "); +// Публичный лейбл (не название площадки) — клиентская страница отчёта, юр-риск. +const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.publicLabel).join(", "); function useEstimateId() { if (typeof window === "undefined") return null; @@ -348,7 +349,7 @@ export default function TradeInPage() {