diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 11396792..c3f6bcda 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -30,6 +30,7 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} + browser: ${{ steps.filter.outputs.browser }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -43,10 +44,23 @@ jobs: # [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать. - 'tradein-mvp/uv.lock' - 'tradein-mvp/pyproject.toml' + # auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ + # репы и монтируется в tradein-backend (/app/auth/roles.yaml). + # tests/test_rbac.py читает именно его, поэтому правка ролей обязана + # гонять и этот гейт. Без строки правка roles.yaml не запускала НИ + # ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main + # уехал красный test_get_role_known_users (2026-07-30 → PR #2587). + - 'auth/**' - '.forgejo/workflows/ci-tradein.yml' frontend: - 'tradein-mvp/frontend/**' - '.forgejo/workflows/ci-tradein.yml' + browser: + # Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml + # members = backend + packages/*), со своим Dockerfile и без pyproject, + # поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе. + - 'tradein-mvp/browser/**' + - '.forgejo/workflows/ci-tradein.yml' backend-tests: runs-on: ubuntu-latest @@ -89,15 +103,73 @@ jobs: run: uv sync --frozen - name: Run pytest (tradein-mvp/backend) - # DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает - # ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state - # leak из другого test-модуля, pre-existing. Второй исторический deselect - # (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в - # полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан - # совпадать с test-job в deploy-tradein.yml. - run: | - uv run pytest -q \ - --deselect "tests/test_search_api.py::test_search_cache_hit" + # БЕЗ deselect'ов — сьют гоняется целиком (#2722). + # + # Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit` + # с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — + # global-state leak из другого модуля». Объяснение было неверным в обеих + # половинах: тест падал и в изоляции тоже (401 vs 200), потому что он — + # единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка + # X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что + # фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке; + # заголовок добавлен, deselect снят, полный прогон зелёный. + # + # Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же + # класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо + # помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом. + # + # NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого + # deselect'а — он остаётся до #2680, который правит тот файл. Расхождение + # безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный. + run: uv run pytest -q + + # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: + # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог + # вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог: + # 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом + # правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину. + # + # Почему НЕ переиспользуем backend-job: + # 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит; + # 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock; + # 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют. + browser-tests: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.browser == 'true' + # Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего + # pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле + # взять нативный job-таймаут, чем тащить плагин ради одного каталога. + timeout-minutes: 10 + defaults: + run: + working-directory: ./tradein-mvp/browser + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + # 3.12 — как в browser/Dockerfile (FROM python:3.12-slim). + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install test deps + # ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки + # Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser + # (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты + # мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib. + # pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам + # крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов. + # + # aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`), + # то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы + # проверку версии, которой в проде нет. + run: pip install pytest aiohttp + + - name: Run pytest (tradein-mvp/browser) + # Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего + # не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped. + run: pytest -q frontend-checks: runs-on: ubuntu-latest @@ -133,3 +205,9 @@ jobs: - name: Lint (next lint) # Blocking: любая ESLint-ошибка → job RED. run: npm run lint + + - name: Mera-public isolation guard (#2631) + # Blocking: статический import-graph публичного лэндинга не должен + # достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/ + # GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545. + run: npm run check:mera-public-isolation diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 48a47220..a261bc30 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -52,6 +52,14 @@ jobs: backend: - 'backend/**' - 'data/sql/**' + # auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в + # backend и в tradein-backend). Правка ролей/пользователей меняет + # поведение backend/tests/test_rbac.py, но сам файл лежит вне + # 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала + # в main без единого прогона. Так и случилось 2026-07-30: user2 + # переведён в expired, test_get_role_known_users стал красным и + # доехал до main незамеченным (починен в PR #2587). + - 'auth/**' - '.forgejo/workflows/ci.yml' frontend: - 'frontend/**' diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9ed960aa..7303d4cf 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -16,6 +16,10 @@ on: - ".forgejo/workflows/deploy.yml" - "data/sql/**" - "ops/glitchtip-auth-forwarder/**" + # Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом + # деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы + # до прода до следующего чужого коммита в backend/. + - "ops/db-bootstrap/**" workflow_dispatch: concurrency: @@ -320,6 +324,70 @@ jobs: echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader" fi + # ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ────────────── + # Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский + # data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в + # подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться + # в БД gendesign, даже если кто-то забудет про разделение; при этом триггер + # `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам. + # Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный + # трекинг, имена файлов двух каталогов не конфликтуют между собой. + # Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение + # здесь останавливает деплой (exit 1) до подъёма нового кода. + # `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него + # берётся AUTH_DB_PASSWORD. + echo "→ Bootstrapping auth database (idempotent)" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \ + < ops/db-bootstrap/create_auth_db.sql \ + || { echo "FAILED to create auth database"; exit 1; } + + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c " + CREATE TABLE IF NOT EXISTS _schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + " + + for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do + fname=$(basename "$sql_file") + # `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с + # лишним пробелом/CR ломает сравнение с "0" и миграция молча считается + # применённой. + applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -tAc \ + "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \ + | tr -d '[:space:]') + if [ "$applied" = "0" ]; then + echo "→ Applying auth migration: $fname" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \ + < "$sql_file" \ + || { echo "FAILED on auth migration: $fname"; exit 1; } + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -c \ + "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" + else + echo "✓ Already applied (auth): $fname" + fi + done + echo "All auth migrations applied." + + # Пароль роли auth_app из env (post-migration bootstrap): миграция + # data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт + # только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка: + # PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля. + if [ -n "${AUTH_DB_PASSWORD:-}" ]; then + echo "→ Applying auth_app password from env" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \ + -v "pw=$AUTH_DB_PASSWORD" \ + < ops/db-bootstrap/set_auth_app_password.sql + else + echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app" + fi + # Build local-only sidecar images (glitchtip-auth-forwarder). # Эти services не в GHCR — сборка происходит на VPS на каждом deploy. # Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись. diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml new file mode 100644 index 00000000..db0304ba --- /dev/null +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -0,0 +1,38 @@ +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS- +# сертификата для meraocenka.ru, т.е. осмысленны ТОЛЬКО против прода после +# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) — +# страхует от случайной регрессии периметра (например, будущий PR по ошибке +# открывает B2B-путь на публичном домене, или basic_auth gate на gendsgn.ru +# случайно снимают). +# +# ДО того как появится DNS A-record meraocenka.ru → IP VPS, проверки 1 и 2 +# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс, +# просто домен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового +# домена и обязаны быть зелёными всегда. +name: perimeter-smoke-mera + +on: + workflow_dispatch: {} + schedule: + # Раз в сутки, 06:17 UTC — вне пиков, время произвольное. + - cron: '17 6 * * *' + +concurrency: + group: perimeter-smoke-mera + cancel-in-progress: false + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Run perimeter smoke checks + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh diff --git a/Caddyfile b/Caddyfile index 7e7ce21a..1e6a0e25 100644 --- a/Caddyfile +++ b/Caddyfile @@ -11,6 +11,13 @@ # Users managed via caddy/users.caddy.snippet (git history = audit trail). # Public exclusions: /health (liveness probe), /preview/* (static mockups). # +# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`, +# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ +# import'а — у trade-in своя авторизация (форма входа + opaque session-cookie, +# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder +# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для +# него — НЕ трогать caddy/users.caddy.snippet). +# # IMPORTANT: route { } block is required to preserve directive order. # Without route { }, Caddy executes directives in hard-coded default order # (basic_auth runs before handle), making /health and /preview/* exclusions @@ -70,26 +77,56 @@ gendsgn.ru { # Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS. @uipreview path /trade-in/ui-preview/* /trade-in/_next/static/* handle @uipreview { - reverse_proxy tradein-frontend:3000 + reverse_proxy tradein-frontend:3000 { + # #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и + # @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент + # мог бы прислать свой X-Authenticated-User. Сейчас инертно + # (страница статична, у tradein-frontend нет секрета для + # X-Internal-Auth-Secret), но убираем ради единообразия периметра, + # а не полагаясь на то, что downstream ничего не делает с заголовком. + header_up -X-Authenticated-User + } } - # Auth gate (applies to all routes below within this route block). - import caddy/users.caddy.snippet - - # Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack, - # подключен через gendesign_shared network. Routes ДО универсального handle - # потому что Caddy матчит handle-блоки сверху вниз. + # #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker + # stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО + # `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную + # авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка + # роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth + # ей больше не нужен и не должен применяться (short-circuit сверху вниз, + # как /health и /preview/* выше). + # + # X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`), + # НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот + # блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}` + # никогда не резолвится авторизованным юзером на этом пути. + # Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`): + # старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`) + # НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле + # пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ + # строку (`ReplaceKnown`), т.е. upstream получил бы буквально + # `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode= + # "dual", `app/core/config.py`) это НЕ подмена личности — legacy path + # (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не + # найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не + # security-дырой, а fail-closed-but-сломанной (все trade-in запросы без + # session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин). + # `-Field` остаётся правильным выбором не потому что Set был бы дырой, а + # потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой + # "удалить заголовок" (Caddyfile reverse_proxy directive: `-` = + # delete) — корректное поведение не должно зависеть от того, как именно + # Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции. + # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается + # из env (Set-операция с непустым значением, никак не связана с auth-гейтом + # basic_auth), это единственное, что теперь отсекает подделку заголовков + # изнутри gendesign_shared network для legacy dual-mode пути. handle /trade-in/api/* { # `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api; # FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только # префикса basePath /trade-in (Next.js basePath leak). uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { - header_up X-Authenticated-User {http.auth.user.id} - # #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up - # с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret — - # тот же механизм, что защищает X-Authenticated-User выше. Пусто пока - # TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет). + header_up -X-Authenticated-User header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -98,6 +135,23 @@ gendsgn.ru { # Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share # (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL # в адресной строке требует отдельного Next-app с basePath=/sale-share. + # #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на + # /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и + # для остального /trade-in). Это НЕ делает страницу публичной: она всё + # ещё за собственной авторизацией trade-in — `RouteGuard` во фронте + # (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на + # бэке; без валидной сессии юзер получит редирект на /login, а не + # контент. Смысл переноса — не открыть страницу всем, а убрать + # несогласованность: короткий URL не должен быть строже (Caddy + # basic_auth) целевого адреса, к которому и так уже нет + # basic_auth-барьера (только собственный login trade-in). + # + # ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до + # ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не + # показывается (deny в auth/roles.yaml для pilot и analyst + в + # DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт + # на страницу, а гейт стоит на роли — для всех, кроме admin, короткий + # адрес приведёт на NoAccessScreen. @saleshare path /sale-share /sale-share/ handle @saleshare { redir /trade-in/sale-share permanent @@ -110,13 +164,17 @@ gendsgn.ru { handle @tradein { # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL reverse_proxy tradein-frontend:3000 { - header_up X-Authenticated-User {http.auth.user.id} - # #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env - # (стирает клиентский), на случай SSR-forwardʼa фронтом в backend. + # См. комментарий над /trade-in/api/* выше — та же логика (явное + # удаление вместо Set с пустым {http.auth.user.id}). + header_up -X-Authenticated-User header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } + # Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и + # handle {} ниже). Trade-In уже отработал и short-circuit'нул выше. + import caddy/users.caddy.snippet + handle /api/* { reverse_proxy backend:8000 { header_up X-Authenticated-User {http.auth.user.id} @@ -135,6 +193,129 @@ www.gendsgn.ru { redir https://gendsgn.ru{uri} permanent } +# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала). +# +# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru +# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" — +# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*). +# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем +# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов +# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два +# handle (корень "/" + статика Next _next/*), всё остальное — финальный +# catch-all `handle { respond 404 }`. Регресс-тест на эту модель: +# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не +# 200/401 — т.е. не был случайно проброшен). +# +# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же +# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в +# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится +# на internal-путь /trade-in/mera-public (страница-заглушка, +# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in +# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса, +# это не HTTP-редирект браузера. +# +# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт +# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy +# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг +# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy +# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не +# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит +# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же +# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются — +# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что +# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО +# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed +# validations/hostname/hour) — не критично, просто подождать; `docker volume +# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval). +meraocenka.ru { + encode zstd gzip + + log { + output file /var/log/caddy/meraocenka.ru.log + } + + # Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на + # полноценную страницу). rewrite добавляет basePath-префикс только для + # Caddy→backend хопа, пользователь /trade-in никогда не видит. + handle / { + rewrite * /trade-in/mera-public + reverse_proxy tradein-frontend:3000 { + # Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже. + # Этот блок вообще не под basic_auth, поэтому анонимный клиент + # тем более может прислать свой X-Authenticated-User. Сейчас + # инертно (лэндинг статичен, backend-вызовов нет), но снимаем + # ради единообразия периметра, а не полагаясь на то, что + # downstream ничего не делает с заголовком — иначе на этапе 5, + # когда откроется публичный /estimate, это станет дырой. + header_up -X-Authenticated-User + } + } + + # Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается + # на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с + # basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle + # ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ + # документ был бы недоступен с публичной страницы. + # + # Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*. + # B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history) + # под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это — + # в scripts/smoke-mera-perimeter.sh. + handle /trade-in/mera-public/* { + reverse_proxy tradein-frontend:3000 { + header_up -X-Authenticated-User + } + } + + # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же + # basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS + # чанки), сами по себе не содержат ни B2B-данных, ни секретов. + # + # Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview + # (:78), который в проде доказал, что этого хватает для рендера. Широкий + # `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок, + # CPU-нагрузка по запросу), который на лэндинге не используется вообще: + # next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется. + handle /trade-in/_next/static/* { + reverse_proxy tradein-frontend:3000 { + header_up -X-Authenticated-User + } + } + + # #2631: favicon — единственный корневой статик, который браузер запрашивает + # сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next + # по корневому пути через basePath /trade-in. + handle /favicon.ico { + rewrite * /trade-in/favicon.ico + reverse_proxy tradein-frontend:3000 { + header_up -X-Authenticated-User + } + } + + # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, + # /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется. + handle { + respond 404 + } +} + +# Домены-спутники МЕРА → 301 на канонический meraocenka.ru. +# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации +# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух +# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не +# matcher внутри основного: Caddy матчит по hostname и выпускает свой +# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе +# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но +# лучше завести записи сразу). +# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=. +merahome.ru { + redir https://meraocenka.ru{uri} permanent +} + +meraotsenka.ru { + redir https://meraocenka.ru{uri} permanent +} + # Obsidian Self-hosted LiveSync (CouchDB backend). # Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap # (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). diff --git a/auth/roles.yaml b/auth/roles.yaml index d1e26301..1273eb38 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -39,6 +39,39 @@ roles: - "/admin/**" - "/api/v1/admin/**" - "/trade-in/api/v1/admin/**" + # Внутренние разделы, закрытые от клиентских аккаунтов (решение владельца + # продукта 2026-07-31): «Доля в продаже» — аналитика рынка, «Кэш» — + # состояние кэшей/скраперов. Зеркало deny-списка DB-ролей employee/manager + # (tradein-mvp/backend/app/services/auth_session.py: DB_ROLE_PATHS). + # + # Зачем копия здесь, если клиенты ходят session-cookie'ой: снаружи легаси + # trusted-header ветка НЕДОСТИЖИМА — с #2558 Caddy срезает входящий + # X-Authenticated-User на всём /trade-in/* (`header_up + # -X-Authenticated-User` в handle /trade-in/api/* и в @tradein), так что + # ни один клиентский аккаунт по ней не ходит. Паттерны нужны для другого: + # 1) ВНУТРИСЕТЕВОЙ dual-mode трафик — запросы изнутри gendesign_shared с + # валидным X-Internal-Auth-Secret; ими ходят QA-смоуки вида + # `docker exec tradein-backend curl localhost:8000 + # -H 'X-Authenticated-User: ...'` — они резолвятся именно через + # roles.yaml, и без этих строк смоук показал бы 200 там, где + # реальный клиент получает 403; + # 2) чтобы legacy-pilot не расходился с DB-employee, если dual-режим + # когда-нибудь снова окажется на периметре (откат #2558 / новый + # фронт-прокси) — тогда расхождение молча откроет разделы. + # НЕ удалять как «мёртвые»: они мёртвые только пока Caddy режет заголовок. + # + # Страницы + их API вместе: deny гейтит пункт меню (Topbar через /me), + # саму страницу (RouteGuard) и серверные ручки (rbac_guard). + # + # cache-stats закрыт ГЛОБОМ, а не точным путём, намеренно: точный паттерн + # обходится трейлинг-слэшем ('…/cache-stats/' не равен '…/cache-stats' → + # allowed), и защита повисала бы на Starlette redirect_slashes, а не на + # RBAC. '/**' → '^(?:/.*)?$': сам путь + слэш + подпути, + # но НЕ соседи по префиксу ('…/cache-statistics' не матчится). + - "/trade-in/sale-share/**" + - "/trade-in/cache/**" + - "/trade-in/api/v1/buildings/**" + - "/trade-in/api/v1/trade-in/cache-stats/**" analyst: # #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports, # site-finder, analytics, concept) КРОМЕ admin/data-management. @@ -48,12 +81,28 @@ roles: # для любого role != "admin" → analyst авто-403 на admin-API без доп. кода. # deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating # /admin/** страниц. + # Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst + # ЧАСТИЧНО — асимметрия намеренная, не недосмотр: + # «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ. + # Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ + # только у admin. «Только у админа» = включая внутренние роли, поэтому + # analyst тоже в deny. + # «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не + # продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот + # рабочий инструмент, ради которого роль analyst и заведена + # («видит ВСЁ кроме admin-управления», см. выше). + # Обе стороны этой асимметрии запиннены тестом + # tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny + # — если решение поменяется, тест упадёт и заставит обновить и его, и этот + # комментарий, а не тихо разойтись с реальностью. paths: - "/**" deny: - "/admin/**" - "/api/v1/admin/**" - "/trade-in/api/v1/admin/**" + - "/trade-in/sale-share/**" + - "/trade-in/api/v1/buildings/**" expired: # Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в # caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть @@ -70,7 +119,8 @@ users: admin: admin kopylov: pilot user1: pilot - user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09) + user2: expired # «Брусника» — доступ закрыт 2026-07-30 (решение владельца продукта; + # ранее: восстановлен 2026-07-13, trial-expire 2026-07-09) user3: pilot user4: pilot user5: pilot diff --git a/backend/app/core/auth_db.py b/backend/app/core/auth_db.py new file mode 100644 index 00000000..ffde543a --- /dev/null +++ b/backend/app/core/auth_db.py @@ -0,0 +1,269 @@ +"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»). + +Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: +`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в +теле модуля, db.py:8). Сделай мы так же для БД `auth` — приложение начало бы +падать на старте везде, где реестр не сконфигурирован: локально, в pytest и на +любом стенде, где переменных AUTH_* нет. Здесь engine создаётся ЛЕНИВО, при +первом реальном обращении. + +Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас — Caddy basic_auth +ещё стоит и снимается последним PR эпика): + + * `AUTH_MODE=legacy` (ДЕФОЛТ; `settings.auth_session_enabled is False`) — в этот + модуль не заходит никто: `app.main.rbac_guard` в этом режиме куку не читает + вовсе. Пустая конфигурация БД `auth` при этом не ошибка ни на импорте, ни в + рантайме; ни одно соединение с БД `auth` не открывается. + * Режим включён (`dual`/`db_only`) + не сконфигурированный реестр — обращение поднимает + `AuthDatabaseNotConfiguredError` с внятным текстом. Именно исключение, а НЕ + тихий возврат «сессия не найдена»: молчаливая деградация означала бы, что все + владельцы валидных кук выглядят как анонимы, то есть массовый отказ доступа + под видом «просто не залогинен» — либо, если guard в этот момент откатывается + на trusted-header, наоборот, раздача прав в обход реестра (включая аккаунты с + access_state 'disabled'). Оба исхода обязаны быть громкими. + +«Птица» реестр только ЧИТАЕТ: сессии выдаёт и отзывает единственная форма входа — +у «Меры». Здесь нет и не должно появиться ни create-, ни revoke-пути. + +Сам DSN этот модуль НЕ выбирает и НЕ склеивает — берёт готовый у +`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из +`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто). + +⚠️ В DSN — пароль роли `auth_app`. Он не логируется и не попадает в текст +исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже — константы, а ошибку +разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и +заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в traceback. +Добавляешь сюда `logger`/`raise ... {dsn}` — не добавляй. + +`create_engine` сам по себе к серверу не ходит (пул коннектов ленивый) — то есть +одна лишь сборка engine доказывает только «DSN не пуст и парсится». Поэтому +`require_auth_db_configured` (fail-fast старта) дополнительно ОТКРЫВАЕТ соединение +и делает `SELECT 1`: неверный пароль, опечатка в хосте, отсутствующая БД и +отозванная роль обязаны ронять деплой, а не превращаться в «ни у кого нет сессии». + +Зеркало по подходу: tradein-mvp/backend/app/core/auth_db.py («Мера»). Синхронизация +руками — стеки разные, общего кода между ними нет и заводить его этот эпик не +собирается. +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import Engine, create_engine, text +from sqlalchemy.exc import ArgumentError +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + + +class AuthDatabaseNotConfiguredError(RuntimeError): + """`AUTH_MODE` не `legacy`, а DSN БД `auth` не задан/не разобрался.""" + + +class AuthDatabaseUnreachableError(RuntimeError): + """DSN синтаксически корректен, но соединиться по нему не удалось (старт приложения).""" + + +_NOT_CONFIGURED_MSG = ( + "Приём сессионной куки включён (AUTH_MODE=dual|db_only), но реестр людей " + "(БД `auth`) не сконфигурирован: пусты и AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — " + "подключаться не к чему. Задай в backend/.env.runtime AUTH_DB_PASSWORD (пароль " + "роли auth_app; остальные части DSN — AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/" + "AUTH_DB_USER — имеют прод-дефолты), либо целиком AUTH_DATABASE_URL, либо верни " + "AUTH_MODE=legacy (сегодняшнее поведение: Caddy basic_auth + заголовок " + "X-Authenticated-User)." +) + +_UNREACHABLE_MSG = ( + "Приём сессионной куки включён (AUTH_MODE=dual|db_only), DSN разобрался, но " + "соединиться с БД `auth` не удалось (см. причину ниже: хост/порт/база/роль/пароль " + "или сеть). Старт прерван намеренно: иначе сломанная конфигурация выглядела бы как " + "«ни у кого нет сессии» — сутками, при живом приложении и 200-х в ответах. Проверь " + "AUTH_DB_* в backend/.env.runtime и пароль роли auth_app (data/sql/auth/002), либо " + "верни AUTH_MODE=legacy." +) + +# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную +# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`. +_MALFORMED_DSN_MSG = ( + "DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан " + "явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема " + "обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ " + "подставлен: в нём пароль роли auth_app." +) + +# Кеш engine/factory + защита от гонки: rbac_guard будет резолвить сессию на каждом +# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты +# уходят в threadpool). Без лока два одновременных первых запроса создали бы два +# engine — то есть два независимых пула коннектов, один из которых потеряется. +_LOCK = threading.Lock() +_engine: Engine | None = None +_session_factory: sessionmaker[Session] | None = None + + +def _build() -> tuple[Engine, sessionmaker[Session]]: + """Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка. + + DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*) — + здесь только «пусто или нет» и создание engine. + + `pool_size`/`max_overflow` не переопределяем: дефолтов SQLAlchemy (5+10) хватает + с запасом — на запрос приходится один короткий SELECT, а раз в 5 минут ещё и + UPDATE sliding-refresh. + + А вот таймауты переопределяем, и это не тюнинг, а требование: реестр — НЕ + критический путь «Птицы», его сбой обязан деградировать за секунды, а не за + минуты (в dual-режиме деградация — уход на легаси-заголовок, в db_only — 401). + * `connect_timeout=3` (libpq, секунды). Без него дропнутые SYN (хост поднят, но + недоступен по сети / фаервол молча глотает пакеты) держат попытку соединения + до TCP-таймаута ОС — на Linux порядка 130 с. `pool_pre_ping=True` делает такую + попытку на КАЖДОМ checkout'е. + * `statement_timeout=3000` (мс, серверный). Ограничивает уже установленное + соединение: залипший SELECT/UPDATE в auth-пути не имеет права висеть дольше. + * `pool_timeout=3` — ожидание свободного коннекта в пуле. Дефолтные 30 с в + auth-пути не нужны никогда: лучше быстро сдаться. + Резолв сессии в rbac_guard уходит в threadpool (`run_in_threadpool`), так что эти + ожидания не блокируют event loop, — но они всё равно держат worker-поток и время + ответа, поэтому короткие. + """ + dsn = settings.resolved_auth_database_url + if not dsn: + raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG) + try: + engine = create_engine( + dsn, + pool_pre_ping=True, + future=True, + pool_timeout=3, + connect_args={"connect_timeout": 3, "options": "-c statement_timeout=3000"}, + ) + except (ArgumentError, ValueError): + # ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до + # `int(port)` и падает с `invalid literal for int() with base 10: 'w'`, где + # 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None` обязателен: он + # гасит цепочку, иначе исходная ошибка (а с ней и этот кусок) печатается в + # traceback как «During handling of...». + raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None + factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) + return engine, factory + + +def _ensure_built() -> tuple[Engine, sessionmaker[Session]]: + global _engine, _session_factory + # Быстрый путь читает глобалы РОВНО ОДИН раз, в локальные переменные. Читать их + # второй раз в `return` нельзя: между проверкой и возвратом может вклиниться + # `reset_auth_db()` (обнуляет оба под локом) — и функция вернула бы (None, None), + # то есть вызывающий упал бы на `factory()` → `TypeError: 'NoneType' object is not + # callable` прямо в auth-пути. + engine, factory = _engine, _session_factory + if engine is not None and factory is not None: + return engine, factory + with _LOCK: + if _engine is None or _session_factory is None: + _engine, _session_factory = _build() + return _engine, _session_factory + + +def get_auth_engine() -> Engine: + """Engine БД `auth` (создаётся при первом вызове). + + Raises: + AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни + AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. + """ + engine, _ = _ensure_built() + return engine + + +def get_auth_session_factory() -> sessionmaker[Session]: + """Session-factory БД `auth` (создаётся при первом вызове). + + Raises: + AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни + AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. + """ + _, factory = _ensure_built() + return factory + + +@contextmanager +def auth_session() -> Iterator[Session]: + """Сессия к БД `auth`, закрывается на выходе из блока. + + Это НЕ `app.core.db.get_db`: там продуктовая БД gendesign, где таблиц + `users`/`sessions` реестра нет. Прямой вызов из роутов не предполагается — + ходи через `app.services.auth_session.resolve_session_token()`. + """ + factory = get_auth_session_factory() + with factory() as db: + yield db + + +def _probe_connection(engine: Engine) -> None: + """Открывает соединение и делает `SELECT 1`. Вынесено функцией ради тестов. + + Отдельная функция, а не две строки в `require_auth_db_configured`: тестам нужна + точка подмены, чтобы проверять ветвление старта, не поднимая Postgres. + """ + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + + +def require_auth_db_configured() -> None: + """Fail-fast для старта приложения: включённый режим обязан иметь РАБОЧИЙ реестр. + + Вызывается из `lifespan` (`app/main.py:111`). Смысл проверки именно на старте: если + сломанная конфигурация обнаружится только в rbac_guard, там её поймает общий + `except` вокруг резолва сессии, и она будет выглядеть как «ни у кого нет сессии» — + сутками, потому что продуктовая БД жива и приложение работоспособно, а сигнал + остаётся только в логах. Дешевле не стартовать. + + Проверяется ИМЕННО СОЕДИНЕНИЕ, а не только синтаксис DSN. `create_engine` к серверу + не ходит вовсе (пул ленивый), поэтому одна лишь сборка engine отлавливала бы ровно + два случая — «DSN пуст» и «DSN не парсится», — а весь класс вероятных ошибок + (неверный AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная + роль auth_app, нет сетевой связности) проходил бы мимо и материализовался как та + самая тихая деградация, ради которой эта функция и заведена. Проба короткая: + `connect_timeout=3` в `_build`. + + Цена — контейнер не поднимется, пока БД `auth` недоступна. Это осознанно: реестр + живёт на ТОМ ЖЕ сервере, что и продуктовая БД (сервис `postgres` корневого + docker-compose.prod.yml, см. `app/core/config.py`), так что «реестр недоступен, а + продукт работоспособен» — состояние вырожденное, а `restart: unless-stopped` + поднимет контейнер, как только Postgres вернётся. + + Режим `legacy` (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта. + Дефолтное поведение обязано оставаться ровно сегодняшним. + + Raises: + AuthDatabaseNotConfiguredError: режим не `legacy`, но DSN пуст или не разобрался. + AuthDatabaseUnreachableError: DSN разобрался, но соединиться не удалось. + """ + if not settings.auth_session_enabled: + return + engine, _ = _ensure_built() + try: + _probe_connection(engine) + except Exception as exc: + # Исходную ошибку СОХРАНЯЕМ в цепочке (`from exc`): в ней хост/порт/роль и + # причина отказа — то, ради чего проверка и делается. Пароля libpq в тексте + # ошибок не печатает, а наш DSN сюда не подставляется (см. модульный докстринг). + raise AuthDatabaseUnreachableError(_UNREACHABLE_MSG) from exc + + +def reset_auth_db() -> None: + """Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты). + + Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а + держать в это время лок незачем — ссылки на него уже сняты. + """ + global _engine, _session_factory + with _LOCK: + stale = _engine + _engine = None + _session_factory = None + if stale is not None: + stale.dispose() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d7e73041..9a84b522 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,10 +1,46 @@ import os import warnings -from typing import Annotated +from typing import Annotated, Literal +from urllib.parse import quote -from pydantic import field_validator, model_validator +from pydantic import SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ───── +# Вынесены константами, потому что используются ДВАЖДЫ: как дефолт поля и как +# запасное значение, если переменная окружения задана ПУСТОЙ строкой +# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`). +# +# ⚠️ ХОСТ — главная ловушка, и для «Птицы» она ЗЕРКАЛЬНА ловушке «Меры». +# У «Меры» (tradein-mvp/backend/app/core/config.py:27) дефолт — `gendesign-postgres`, +# потому что внутри ЕЁ стека имя `postgres` резолвится в её собственный контейнер +# (tradein-mvp/docker-compose.prod.yml:143 собирает им продуктовый DATABASE_URL +# `...@postgres:5432/tradein`), и БД `auth` там нет. +# +# У «Птицы» ровно наоборот: её стек и есть главный. Сервис `postgres` в корневом +# docker-compose.prod.yml:22 (postgis/postgis:16-3.4) — это И ЕСТЬ тот сервер, где +# живёт БД `auth`: bootstrap и миграции data/sql/auth/*.sql применяет к нему шаг +# «Apply DB migrations» в .forgejo/workflows/deploy.yml:339-375. Соседи по тому же +# compose-проекту так к нему и обращаются — `@postgres:5432` (docker-compose.prod.yml:232 +# и :265, DATABASE_URL сервисов glitchtip). +# +# Алиас `gendesign-postgres` (docker-compose.prod.yml:43-45) навешен ТОЛЬКО в внешней +# сети `shared` (gendesign_shared) и заведён ради ЧУЖИХ стеков — им и пользуется +# «Мера». Ставить его дефолтом здесь нельзя: в сети `shared` состоят лишь backend и +# worker (`networks: [default, shared]`, строки 152 и 199), а `beat` (строки 201-217) +# сетей не объявляет вовсе — он только в `default`, и `gendesign-postgres` из него +# просто не разрезолвится. `postgres` резолвится из всех трёх. +# +# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера. Публикация `127.0.0.1:5432:5432` +# (docker-compose.prod.yml:31-32) существует только ради SSH-туннеля с хоста и к +# этому пути отношения не имеет. +_AUTH_DB_DEFAULT_HOST = "postgres" +_AUTH_DB_DEFAULT_PORT = 5432 +_AUTH_DB_DEFAULT_NAME = "auth" +# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege: SELECT/ +# INSERT/UPDATE/DELETE на sessions, SELECT + column-level UPDATE на users). +_AUTH_DB_DEFAULT_USER = "auth_app" + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -371,5 +407,201 @@ class Settings(BaseSettings): # на недоступном сервисе. ENV: DADATA_TIMEOUT_S. dadata_timeout_s: float = 8.0 + # ── Эпик «единый вход»: «Птица» ПРИНИМАЕТ сессию общего реестра ──────────── + # Форма входа во всём продукте одна и живёт у «Меры» (/trade-in/login): она + # проверяет пароль и выдаёт сессию в auth.sessions. «Птица» сессии НЕ выдаёт и + # НЕ отзывает — только читает куку и резолвит её в человека. Кука host-only на + # gendsgn.ru с path="/" (tradein-mvp/backend/app/api/v1/auth.py:173-181), + # поэтому браузер шлёт её на оба продукта одного домена. + # + # Режим — ТРЁХЗНАЧНЫЙ, а не булев флаг, и это сделано ради последнего PR эпика: + # legacy (ДЕФОЛТ) — сегодняшнее поведение бит-в-бит: кука не читается вовсе, + # личность берётся из X-Authenticated-User (Caddy basic_auth); + # engine БД `auth` не создаётся, соединение не открывается, + # отсутствие AUTH_* в окружении не роняет старт; + # dual — сначала кука общего реестра, при её отсутствии/сбое реестра + # деградация на легаси-заголовок (переходный режим: popup + # Caddy ещё стоит и прикрывает заголовок от подделки); + # db_only — легаси-ветка НЕДОСТИЖИМА: нет валидной сессии → 401, даже + # если X-Authenticated-User присутствует. + # + # Почему именно так, а не `AUTH_SESSION_ENABLED=true/false`. В dual-режиме сбой + # реестра (или просто отсутствие куки) уводит запрос на trusted-header. Пока + # popup стоит, это безопасно: заголовок на `/api/*` перезаписывает Caddy из + # basic_auth (Caddyfile:178-182), клиент подставить его не может. Ровно в тот + # момент, когда последний PR эпика снимет `basic_auth` + `header_up`, заголовок + # станет полностью клиентским — и та же деградация превратится в ПОЛНЫЙ обход + # аутентификации (`curl -H 'X-Authenticated-User: admin'`). Булев флаг оставлял бы + # это на память мейнтейнера («не забыть выпилить фолбэк»); режим делает переход + # сменой ОДНОГО значения (`AUTH_MODE=db_only`), а недостижимость легаси-ветки в + # нём закреплена тестами (tests/test_auth_session_guard.py, секция db_only). + # Зеркало «Меры»: tradein-mvp/backend/app/core/config.py:91 (`auth_mode`); там + # значений два — легаси-режима у неё уже нет, она на реестре с #2552. + # + # ⚠️ ДЕФОЛТ `legacy` — ЧАСТЬ КОНТРАКТА PR, А НЕ ЗАГЛУШКА: после мержа прод обязан + # работать ровно как сегодня (popup Caddy снимается последним PR эпика). + # Читатели режима: `app.main.rbac_guard` (какой источник личности и есть ли + # фолбэк), `app.services.auth_session.resolve_session_token` и + # `app.core.auth_db.require_auth_db_configured` — через производное свойство + # `auth_session_enabled` ниже. + # + # Включение на проде = одна переменная: AUTH_DB_PASSWORD в backend/.env.runtime + # уже есть (её пишет ops и читает .forgejo/workflows/deploy.yml:381-386, чтобы + # сделать ALTER ROLE auth_app), остальные части DSN имеют прод-дефолты. + # ENV: AUTH_MODE. + auth_mode: Literal["legacy", "dual", "db_only"] = "legacy" + + # DSN БД `auth` целиком. Пусто по умолчанию — задавать руками не обязательно: + # см. `resolved_auth_database_url` ниже, при пустом значении DSN собирается из + # AUTH_DB_PASSWORD + частей. Явное значение, если оно есть, выигрывает всегда + # (аварийный обход: другой хост, sslmode, байпас пула). ENV: AUTH_DATABASE_URL. + auth_database_url: str = "" + # Пароль роли auth_app. Живёт в ОДНОМ месте — этой переменной: требовать вдобавок + # целиковый AUTH_DATABASE_URL значило бы держать один секрет в двух местах + # (сменили пароль роли, забыли переписать DSN → вход ложится молча и целиком). + # + # SecretStr, а не str как у соседних секретов файла: `repr(settings)` и + # `settings.model_dump()` печатают обычные str-поля ДОСЛОВНО. Сегодня их никто не + # рендерит, но появиться такой рендер может тихо — с SecretStr он напечатает + # `SecretStr('**********')`. Значение достаётся ровно в одном месте — + # `.get_secret_value()` в резолвере ниже. Соседи (openai_api_key, dadata_api_secret, + # database_url) остались str — это предсуществующее положение, а не «там безопасно». + # ENV: AUTH_DB_PASSWORD. + auth_db_password: SecretStr = SecretStr("") + # Остальные части — с дефолтами, верными для ЭТОГО стека (см. константы выше и + # разбор ловушки хоста). Переопределяются через ENV для локального запуска (напр. + # AUTH_DB_HOST=localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля). + # ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER. + auth_db_host: str = _AUTH_DB_DEFAULT_HOST + auth_db_port: int = _AUTH_DB_DEFAULT_PORT + auth_db_name: str = _AUTH_DB_DEFAULT_NAME + auth_db_user: str = _AUTH_DB_DEFAULT_USER + + # Имя cookie сессии. ОБЯЗАНО совпадать с тем, которым пользуется «Мера» + # (tradein-mvp/backend/app/core/config.py:84-86) — иначе браузер шлёт куку, а + # «Птица» её не узнаёт и молча остаётся без сессии. + # + # ⚠️ Имя ИСТОРИЧЕСКОЕ: оно родилось в trade-in до того, как реестр стал общим, и + # «tradein_» в нём теперь ни о чём не говорит. Переименование разлогинивает ВСЕХ + # и СРАЗУ в обоих продуктах (старую куку никто больше не читает), поэтому меняется + # только отдельным решением — синхронно в обоих стеках и с обдуманным моментом. + # ENV: SESSION_COOKIE_NAME. + session_cookie_name: str = "tradein_session" + # TTL сессии в часах (720 = 30 дней) — тот же дефолт, что у «Меры» + # (tradein-mvp/backend/app/core/config.py:88). «Птица» сессии не выдаёт, поэтому + # значение используется ЕДИНСТВЕННЫМ образом: на сколько sliding-refresh отодвигает + # expires_at (app/services/auth_session.py). Держать его РАВНЫМ значению «Меры» + # обязательно — иначе срок жизни сессии начнёт зависеть от того, в каком продукте + # человек кликнул последним. ENV: SESSION_TTL_HOURS. + session_ttl_hours: int = 720 + + @field_validator("auth_mode", mode="before") + @classmethod + def _blank_auth_mode_means_legacy(cls, value: object) -> object: + """`AUTH_MODE=` (пустая строка) → `legacy`, а не ValidationError на импорте. + + Та же ловушка, что у `AUTH_DB_PORT` ниже: `settings = Settings()` выполняется на + уровне модуля, поэтому невалидное значение роняет ИМПОРТ конфига и уводит + контейнер в restart-loop. Сценарий тот же — ops копирует блок AUTH_* в + .env.runtime и заполняет только пароль. Пустое значение обязано означать + «оставили как было», то есть сегодняшнее поведение. + + Регистр и обрамляющие пробелы нормализуются: `AUTH_MODE=DB_ONLY ` — очевидная + опечатка со смыслом, а не запрос на падение. Непустой мусор (`AUTH_MODE=off`) + по-прежнему валится, и правильно: молча трактовать его как `legacy` значило бы + тихо оставить продукт на trusted-header после снятия popup'а. + """ + if isinstance(value, str): + normalized = value.strip().lower() + return normalized or "legacy" + return value + + @property + def auth_session_enabled(self) -> bool: + """Читает ли «Птица» сессионную куку общего реестра (то есть режим не `legacy`). + + Производное от `auth_mode`, а не отдельное поле: два независимых переключателя + рано или поздно разъезжаются, и получилось бы состояние «куку читаем, но режим + легаси» (или наоборот), которого нет ни в одном настоящем сценарии. + + Держит инвариант «`legacy` = ни одного коннекта к реестру»: по этому свойству + закорачиваются `app.services.auth_session.resolve_session_token` и + `app.core.auth_db.require_auth_db_configured`. Разница между `dual` и `db_only` + свойству не видна и не должна быть — она касается только фолбэка на + легаси-заголовок и живёт в `app.main.rbac_guard`. + """ + return self.auth_mode != "legacy" + + @field_validator("auth_db_port", mode="before") + @classmethod + def _blank_auth_db_port_means_default(cls, value: object) -> object: + """`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте. + + Симметрия с host/name/user, у которых пустое значение переменной падает + обратно на дефолт в резолвере. Для порта того же добиться нельзя: он + типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а + `settings = Settings()` выполняется на уровне модуля — то есть `AUTH_DB_PORT=` + в .env.runtime роняло бы ValidationError на импорте конфига и уводило контейнер + в restart-loop. Причём В ЛЮБОМ режиме, включая дефолтный (флаг выключен), где к + БД `auth` не идёт ни одного обращения — ровно тот инвариант «дефолт не трогаем», + который держит весь этот PR. + + Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и + заполняет только пароль — остальные строки остаются пустыми намеренно. + + `mode="before"` — потому что вмешаться надо ДО приведения к int. Непустой мусор + (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: это опечатка со смыслом, + а не «оставил пустым». + """ + if isinstance(value, str) and not value.strip(): + return _AUTH_DB_DEFAULT_PORT + return value + + @property + def resolved_auth_database_url(self) -> str: + """DSN БД `auth` — единственный источник правды для `app.core.auth_db`. + + Приоритет: + 1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда. + 2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей. + 3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка сама + по себе: при `AUTH_MODE=legacy` (дефолт) сюда не заходит никто. + Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db`, и только + когда реестр реально понадобился. + + ⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст + исключений, не отдавать наружу (`/health`, `/docs`, метрики). + + Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, `#`, + `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@` сдвинул бы + границу host, `/` открыл бы path. Разбор дал бы либо ошибку, либо, что хуже, + МОЛЧА другой хост/базу. По той же причине экранируется имя пользователя. + + А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy + раскодирует обратно только userinfo (user/password), а path отдаёт как есть. + Прогони мы имя БД через `quote`, в сервер уехало бы литеральное `c%2Fd` вместо + `c/d`. Хосту %-кодирование тоже только мешает — оно поломало бы IPv6-скобки. + """ + explicit = self.auth_database_url.strip() + if explicit: + return explicit + + # `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это + # опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится): + # ведущий/хвостовой пробел может быть частью настоящего пароля. + password = self.auth_db_password.get_secret_value() + if not password.strip(): + return "" + + user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="") + secret = quote(password, safe="") + host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST + port = self.auth_db_port + name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME + # Схема — ровно та же, что у продуктового database_url (psycopg v3; + # `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в + # зависимостях нет). + return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}" + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index a13903ed..ee3969ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,11 +3,14 @@ import logging import os import re +import threading +import time from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager import sentry_sdk from fastapi import FastAPI, Request +from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response from sentry_sdk.integrations.celery import CeleryIntegration @@ -41,10 +44,12 @@ from app.api.v1 import ( trade_in, users, ) +from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings from app.observability.sentry_scrub import scrub_sensitive_query +from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) @@ -97,6 +102,18 @@ if settings.glitchtip_dsn: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Эпик «единый вход», fail-fast: AUTH_MODE=dual|db_only обязан иметь РАБОЧИЙ + # реестр — проверяется не только разбор DSN, но и живое соединение (`SELECT 1`, + # app/core/auth_db.py). Не соединились → контейнер НЕ стартует. Режим `legacy` + # (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта. + # + # Почему именно на старте, а не «разберёмся в рантайме»: неверный пароль, опечатка + # в хосте, не созданная БД `auth` иначе ловились бы `except`'ом вокруг резолва + # сессии в rbac_guard, и сломанная конфигурация выглядела бы как «ни у кого нет + # сессии» — СУТКАМИ, потому что продуктовая БД жива, приложение отвечает 200, а + # сигнал остаётся только в логах. Дешевле не стартовать: деплой падает сразу и + # громко. + auth_db.require_auth_db_configured() yield @@ -122,10 +139,212 @@ app.middleware("http")(audit_log_middleware) # 3) /api/v1/admin/* — только role=admin, иначе 403. # Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки — # X-Authenticated-User там просто не приходит из Caddy. +# +# Эпик «единый вход»: к правилу 1 добавляется ПЕРВЫЙ источник личности — +# сессионная кука общего реестра (БД `auth`). Выдаёт её единственная форма входа, у +# «Меры» (/trade-in/login); «Птица» сессии только читает. Кука host-only на +# gendsgn.ru с path="/" → браузер шлёт её и сюда. Порядок: кука → легаси-заголовок. +# Дальше — ВСЁ как раньше: роль из auth/roles.yaml, admin-гейт по _ADMIN_API_RE. +# Реестр отвечает на вопрос «кто ты», roles.yaml — «что тебе можно»; продуктовые +# роли реестра (auth.users.role) в «Птицу» намеренно не протаскиваются. +# +# ⚠️ AUTH_MODE=legacy ПО УМОЛЧАНИЮ — popup Caddy basic_auth ещё стоит и снимается +# ПОСЛЕДНИМ PR эпика. Пока режим legacy, этот файл ведёт себя бит-в-бит как до эпика: +# кука не читается, БД `auth` не открывается. `dual` — переходный режим (кука, при её +# отсутствии/сбое реестра фолбэк на заголовок), `db_only` — фолбэка нет вовсе. +# +# ⚠️ ДОЛГ, КОТОРЫЙ ОБЯЗАН БЫТЬ ЗАКРЫТ ДО СНЯТИЯ POPUP'А (не решается этим PR). +# Guard проверяет ровно две вещи: есть ли username в auth/roles.yaml (get_role) и +# admin-гейт по _ADMIN_API_RE. Списки `paths`/`deny` из roles.yaml на бэкенде НЕ +# применяются — это зафиксировано в самом auth/roles.yaml:33-35 («path-level +# enforcement делает frontend RouteGuard»). Следствие: в момент включения режима +# «Птицу» получает КАЖДЫЙ аккаунт реестра, чей username совпадает с записью в +# roles.yaml, — включая роль `expired` (user2: paths: [], deny: "/**"), которую +# сегодня останавливает только фронт. Это не регрессия (те же люди сегодня в +# caddy/users.caddy.snippet и добираются туда же через basic_auth), но эпик делает +# её несущей: (а) до снятия popup'а отзыв доступа имеет ДВА рубильника — +# caddy-snippet и access_state в реестре, их надо держать синхронными; (б) после +# снятия roles.yaml остаётся ЕДИНСТВЕННЫМ гейтом, и `expired` в нём станет чисто +# фронтовой фикцией. Перед включением: сверить `auth.users.username` на проде с +# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать +# `expired` как вводящий в заблуждение. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) +def _propagate_authenticated_user(request: Request, username: str) -> None: + """Инжектит `X-Authenticated-User` в ASGI-scope — ПЕРЕЗАПИСЫВАЯ, а не дополняя. + + 🔴 Перезапись, а не «поставить, если отсутствует» — это требование безопасности, + а не стилистика. В бэкенде «Птицы» ОДИННАДЦАТЬ мест читают этот заголовок НАПРЯМУЮ, + мимо guard'а, и решают по нему, кто автор/кому принадлежат данные: + • app/core/audit_middleware.py:169 — атрибуция строки аудита; + • app/api/v1/me.py:30 — чей scope отдать (роль + фильтры); + • app/api/v1/insights.py:74/124/138 — created_by + _require_user (POST/PUT/DELETE); + • app/api/v1/own_projects.py:69/115/131 — created_by + _require_user (POST/PUT/DELETE); + • app/api/v1/parcels.py:1481 — GET /{cad_num}/forecast; + • app/api/v1/parcels.py:1902 — POST /{cad_num}/analyze (created_by рана, + parcels.py:4212, и 3-й аргумент forecast_site_finder_report.delay, :4226); + • сам rbac_guard ниже — легаси-ветка. + Ни одно из них не знает про сессию: для них истина — сырой заголовок. Оставь мы + skip-if-present — клиент с ВАЛИДНОЙ кукой прошёл бы guard как он сам, а во все эти + места уехал бы его собственный подставленный `X-Authenticated-User: <кто угодно>` + (Caddy шлёт этот заголовок на каждый прод-запрос, так что «просто добавить» его + было бы некуда). Ровно этот баг ловили у «Меры» — #2552 post-review, CRITICAL. + Резолвнутая сессия ОБЯЗАНА быть единственным источником личности. + + Механизм: `request.scope` — один и тот же dict, прокинутый ПО ССЫЛКЕ через весь + ASGI-стек (Starlette не копирует scope между слоями). Мутация здесь видна: + • всей downstream-цепочке — мы мутируем ДО вызова call_next(); + • audit-middleware — он ВНУТРЕННИЙ относительно rbac_guard (см. комментарий у + app.middleware("http")(audit_log_middleware) выше: LIFO-регистрация даёт + порядок rbac_guard → audit → router), т.е. его Request строится уже после + мутации. У «Меры» этот слой, наоборот, внешний, и там мутация до него + доезжает только потому, что читается ПОСЛЕ call_next. + + Имена заголовков в ASGI — по спеке всегда lowercase bytes, и uvicorn/TestClient + её соблюдают. Фильтр всё равно нормализует ключ сам (`k.lower()`), а не полагается + на спеку: попади в scope запись `b"X-Authenticated-User"` (другой ASGI-сервер, + самодельный слой, тест-харнесс) — точное сравнение оставило бы её в списке рядом с + нашей. Читатели при этом видели бы правильное значение (`Headers.get` лоуэркейсит + искомый ключ, но не хранимый, так что смешанный регистр не матчится никогда), то + есть дыры нет — но состояние «две записи с одним именем» в scope не должно + существовать: оно ложное по построению и ломает любой обход списка глазами. + `errors="replace"` в encode: латиницей логины реестра не ограничены, а падать + UnicodeEncodeError в auth-пути нельзя. + + NB: `request.headers` САМОГО этого Request уже закеширован (мы читали cookies) и + останется старым. Это не мешает: в session-ветке guard больше не читает заголовок, + а нижележащие слои строят свой Request поверх обновлённого scope. + """ + request.scope["headers"] = [ + (k, v) for k, v in request.scope.get("headers", []) if k.lower() != b"x-authenticated-user" + ] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))] + + +# Троттлинг алерта «реестр не отвечает». Резолв сессии идёт на КАЖДОМ non-public +# запросе с кукой, а `logger.exception` уровня ERROR уезжает событием в GlitchTip +# (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше) — то есть лежащий +# реестр давал бы поток событий, пропорциональный трафику: квота/rate-limit выгорают +# за минуты, и настоящие ошибки этого же периода теряются. Полный traceback печатаем +# не чаще раза в минуту (с числом подавленных за окно), остальное — WARNING без +# exc_info, чтобы факт продолжающегося сбоя всё равно был виден в логах. +# Лок нужен по-настоящему: функция исполняется в threadpool'е, то есть параллельно. +_REGISTRY_FAILURE_ALERT_INTERVAL_S = 60.0 +_REGISTRY_FAILURE_LOCK = threading.Lock() +_registry_failure_last_alert = 0.0 +_registry_failure_suppressed = 0 + + +def _reset_registry_failure_throttle() -> None: + """Сбрасывает окно троттлинга. Для тестов: состояние модульное и живёт между ними.""" + global _registry_failure_last_alert, _registry_failure_suppressed + with _REGISTRY_FAILURE_LOCK: + _registry_failure_last_alert = 0.0 + _registry_failure_suppressed = 0 + + +def _log_registry_failure(path: str) -> None: + """Логирует сбой резолва: раз в окно — ERROR с traceback, иначе WARNING. + + Зовётся ТОЛЬКО из `except`-блока: `logger.exception` берёт traceback из текущего + sys.exc_info(). + """ + global _registry_failure_last_alert, _registry_failure_suppressed + now = time.monotonic() + with _REGISTRY_FAILURE_LOCK: + alert = (now - _registry_failure_last_alert) >= _REGISTRY_FAILURE_ALERT_INTERVAL_S + if alert: + suppressed = _registry_failure_suppressed + _registry_failure_last_alert = now + _registry_failure_suppressed = 0 + else: + suppressed = 0 + _registry_failure_suppressed += 1 + if alert: + logger.exception( + "RBAC: резолв сессии не удался на %s — эти запросы обслуживаются по " + "легаси-пути (Caddy basic_auth + X-Authenticated-User); подавлено таких же " + "за предыдущее окно: %d", + path, + suppressed, + ) + else: + logger.warning( + "RBAC: резолв сессии не удался на %s (traceback подавлен троттлингом, " + "следующий — не раньше чем через %.0f с)", + path, + _REGISTRY_FAILURE_ALERT_INTERVAL_S, + ) + + +def _resolve_session_username(token: str | None, path: str) -> str | None: + """Логин из сессионной куки, либо None, если личность по куке не установлена. + + 🔴 СИНХРОННАЯ и вызывается ТОЛЬКО через `run_in_threadpool` (см. rbac_guard): + внутри — psycopg-I/O (checkout из пула + SELECT, раз в 5 минут ещё UPDATE и + commit). Позови её напрямую из корутины guard'а — и весь API «Птицы» + сериализуется за один round-trip к БД `auth` на каждый запрос, а недоступный + реестр (или исчерпанный пул) заморозит event loop целиком, включая /health. Ровно + этот инцидент уже был на соседнем middleware — #1202, см. комментарий в + app/core/audit_middleware.py:175-181, там он и починен через `run_in_threadpool`. + Токен принимается ГОТОВЫМ (а не `Request`) именно поэтому: разбор Cookie-заголовка + дёшев и делается на loop'е, в поток уезжает только строка. + + None означает ровно одно — «личность по куке не установлена», и вызывающий обязан + трактовать это одинаково во всех трёх случаях: куки нет, кука невалидна (нет + строки / истекла / access_state не active), резолв УПАЛ. + + Поведение при сбое БД `auth` (осознанный выбор, а не «поймали и забыли»): логируем + ERROR с traceback — он уезжает событием в GlitchTip (LoggingIntegration + event_level=ERROR, см. sentry_sdk.init выше), т.е. это алерт, а не строчка, которую + никто не увидит (частота ограничена окном, `_log_registry_failure`), — и в режиме + `dual` деградируем к легаси-ветке, то есть к сегодняшнему поведению: Caddy + basic_auth + X-Authenticated-User. В режиме `db_only` деградации нет: guard + отвечает 401. + + Почему НЕ 503/500. Пока идёт переходный период, popup basic_auth стоит перед + бэкендом, и легаси-ветка защищена ровно тем же, чем защищён весь продукт сегодня, — + множество людей, способных вообще достучаться, не расширяется. Отдавать же 503 + значит класть «Птицу» целиком из-за проблемы, которую basic_auth уже покрывает + (отозванный пароль роли auth_app, пересозданная БД `auth`, исчерпанный пул её + engine — всё это не мешает продуктовой БД gendesign работать). + + Почему это не «тихий фолбэк на легаси». Опасный сценарий — не «реестр упал», а + «реестр не сконфигурирован»: тогда права раздавались бы из roles.yaml в обход + реестра (включая аккаунты с access_state disabled/trial_expired) бессрочно и молча. + Этот сценарий сюда НЕ доходит: конфигурацию проверяет lifespan, причём НЕ на глазок — + `require_auth_db_configured` открывает соединение и делает `SELECT 1`, так что мимо + него не проходят ни пустой/битый DSN, ни неверный пароль, ни опечатка в хосте, ни + отозванная роль (app/core/auth_db.py). Здесь остаётся только второй рубеж — реестр, + отвалившийся ПОСЛЕ успешного старта. + + ⚠️ Отдельно про отзыв доступа: пароли Caddy basic_auth (caddy/users.caddy.snippet) + и `auth.users.access_state` — РАЗНЫЕ списки. Человек, которому в реестре поставили + disabled/trial_expired, свой basic_auth-пароль не теряет, поэтому на время + недоступности реестра деградация возвращает его в строй. То есть отзыв тут не + «строже сегодняшнего», а откатывается к состоянию ДО отзыва — при включении режима + caddy-snippet надо прополоть под список активных аккаунтов реестра. + + ⚠️ Когда последний PR эпика снимет popup, эта деградация обязана уйти вместе с ним: + без basic_auth впереди фолбэк на легаси-заголовок превращается в дыру — заголовок + станет полностью клиентским. Механика перехода уже готова: `AUTH_MODE=db_only` + (см. app/core/config.py), в нём легаси-ветка недостижима и этот возврат None + означает 401, а не «попробуем заголовок». + """ + if not token: + # Нет куки — ни одного обращения к БД `auth`. Это весь сегодняшний трафик. + return None + try: + session_user = resolve_session_token(token) + except Exception: + _log_registry_failure(path) + return None + if session_user is None: + return None + return session_user.username + + @app.middleware("http") async def rbac_guard( request: Request, @@ -134,6 +353,17 @@ async def rbac_guard( # Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User. # СТРОГО gated на settings.testing (default False) — прод RBAC не затронут. # RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware). + # + # ⚠️ Он ОТКЛЮЧАЕТ ВЕСЬ guard целиком, включая session-ветку ниже, — и это сказано + # здесь явно, чтобы не выглядело недосмотром. Следствие для тестов: сессионный путь + # НЕЛЬЗЯ проверять запросом к настоящему `app` через TestClient (conftest ставит + # settings.testing=True глобально, guard просто не отработает, тест «прошёл бы» ни о + # чём). Он и проверяется иначе: tests/test_auth_session_guard.py зовёт ЭТУ САМУЮ + # функцию напрямую, сняв settings.testing через monkeypatch, — то есть прод-код, а + # не копию. Копия guard'а в tests/test_rbac.py про куку намеренно НЕ знает и + # покрывает только режим legacy (там об этом написано). Сдвигать session-ветку ВЫШЕ + # bypass'а нельзя: получился бы полуработающий guard (личность резолвится, а 401/403 + # не применяются) — состояние, которого нет ни в одном настоящем режиме. if settings.testing: return await call_next(request) @@ -141,22 +371,67 @@ async def rbac_guard( if path in _PUBLIC_PATHS: return await call_next(request) - username = request.headers.get("X-Authenticated-User") - if not username: - # Любой non-public path без auth-header → 401. Локальный curl мимо Caddy - # или прокси-фронт без header_up. 401 точнее чем 403 — "сначала - # аутентифицируйся". + # Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а + # гарантия инварианта «legacy = поведение не меняется ни на байт»: в нём не + # трогается даже request.cookies (разбор Cookie-заголовка). + token = ( + request.cookies.get(settings.session_cookie_name) if settings.auth_session_enabled else None + ) + + # 🔴 Резолв — В THREADPOOL. Внутри синхронный psycopg-I/O, а мы в корутине: прямой + # вызов блокировал бы event loop на каждом запросе с кукой (инцидент #1202, тот же + # класс, что чинили в app/core/audit_middleware.py:175-183). `if token` перед + # хопом — не микрооптимизация: без куки резолвить нечего, и весь сегодняшний + # трафик не платит ни за поток, ни за коннект. + session_username = ( + await run_in_threadpool(_resolve_session_username, token, path) if token else None + ) + + if session_username is not None: + username = session_username + # 🔴 До call_next и до всего остального: личность из сессии обязана вытеснить + # клиентский заголовок для одиннадцати прямых читателей (см. функцию). + _propagate_authenticated_user(request, username) + elif settings.auth_mode == "db_only": + # Легаси-ветка ОТКЛЮЧЕНА: нет валидной сессии → отказ, даже если + # X-Authenticated-User присутствует. Это конечное состояние эпика — режим + # включается тем же PR, который снимает `basic_auth` + `header_up` из Caddy и + # тем самым делает заголовок полностью клиентским. Отдельный текст ответа: + # «no authenticated user» ниже говорит про basic_auth, которого в этот момент + # уже нет. return JSONResponse( status_code=401, - content={"detail": "no authenticated user (Caddy basic_auth required)"}, + content={"detail": "valid session required"}, ) + else: + # ---- легаси trusted-header путь — БИТ-В-БИТ как до эпика ---- + header_user = request.headers.get("X-Authenticated-User") + if not header_user: + # Любой non-public path без auth-header → 401. Локальный curl мимо Caddy + # или прокси-фронт без header_up. 401 точнее чем 403 — "сначала + # аутентифицируйся". + return JSONResponse( + status_code=401, + content={"detail": "no authenticated user (Caddy basic_auth required)"}, + ) + username = header_user try: role = get_role(username) except KeyError: # Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ. # Decided 2026-05-25: «человек без ролей вообще ничего не видит». - logger.warning("RBAC: unknown user %r tried %s", username, path) + if session_username is not None: + # Тот же отказ, но отдельным сообщением: «есть в реестре, нет в roles.yaml» — + # это рассинхрон двух списков (типовой при заведении нового аккаунта), а не + # подделка заголовка, и чинится он в другом месте. + logger.warning( + "RBAC: сессия резолвлена в %r, но юзера нет в auth/roles.yaml — отказ на %s", + username, + path, + ) + else: + logger.warning("RBAC: unknown user %r tried %s", username, path) return JSONResponse( status_code=403, content={"detail": "user not in roles config"}, diff --git a/backend/app/services/auth_session.py b/backend/app/services/auth_session.py new file mode 100644 index 00000000..e6f5be12 --- /dev/null +++ b/backend/app/services/auth_session.py @@ -0,0 +1,256 @@ +"""Резолв сессионной куки общего реестра (БД `auth`) — сторона «Птицы». + +Эпик «единый вход»: вместо браузерного popup'а Caddy basic_auth у продукта одна +нейтральная форма входа. Живёт она у «Меры» (`/trade-in/login`): та проверяет +пароль, пишет строку в `auth.sessions` и ставит куку host-only на gendsgn.ru с +`path="/"` — поэтому браузер шлёт её и на `/site-finder/**` тоже. + +«Птица» эту куку ТОЛЬКО ЧИТАЕТ. Здесь нет и не должно появиться `create_session` / +`revoke_session`: выдача и отзыв — исключительная ответственность единственной +формы входа, второй эмитент сессий означал бы два места, где решается «кого +пускать», и расходящиеся правила блокировки. + +Что модуль отдаёт вызывающему: `resolve_session_token(token)` → `SessionUser` +(username + состояние доступа) либо None. Что делать с username дальше — дело +guard'а: авторизация «Птицы» (какие пути кому видны) по-прежнему живёт в +`auth/roles.yaml` (`app.core.auth.get_role`), продуктовые роли реестра +(`auth.users.role` — admin/manager/employee, миграция data/sql/auth/004) сюда +намеренно НЕ протаскиваются: это другая ролевая модель, и её отображение на +roles.yaml — отдельное решение стадии 2, а не побочный эффект резолва сессии. + +Токены опаковые (`secrets.token_urlsafe` на стороне «Меры») — не JWT, не подписаны: +валидность проверяется исключительно наличием строки в БД + `expires_at` + +состоянием доступа юзера. Никакого разделяемого секрета между стеками для этого +не нужно — только доступ к одной БД. + +Имена таблиц (`users`, `sessions`) и колонок — литералы из data/sql/auth/001 и 004; +снаружи в SQL-строку не попадает ничего, значения идут bind-параметрами. + +Зеркало по подходу: tradein-mvp/backend/app/services/auth_session.py («Мера»). Там +модуль дополнительно умеет две схемы (переходный `identity_store`) и выдачу сессий — +здесь этого нет за ненадобностью. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from enum import StrEnum + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core import auth_db +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в 5 +# минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним UPDATE +# (guard резолвит сессию на КАЖДЫЙ non-public запрос). Значение и механика — те же, +# что у «Меры» (tradein-mvp/.../auth_session.py:51): сессия общая, и продлевать её +# два продукта обязаны одинаково. +_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5) + + +class AccessState(StrEnum): + """Состояние доступа аккаунта — значения дословно из `auth.users.access_state`. + + CHECK-констрейнт `users_access_state_ck`, миграция data/sql/auth/004; семантика + оттуда же (решение владельца от 2026-07-31): + active — доступ есть; + trial_expired — пароль верный, но пробный период истёк; + disabled — доступ закрыт владельцем. + + Для «Птицы» все три состояния делятся надвое (`can_sign_in`): отдельный экран + «пробный доступ закончился» — сюжет формы входа, то есть «Меры»; сюда приходит + уже вошедший человек, и всё, что не `active`, для него значит одно — сессии нет. + """ + + ACTIVE = "active" + TRIAL_EXPIRED = "trial_expired" + DISABLED = "disabled" + + @property + def can_sign_in(self) -> bool: + """True только для `active` — единственная проверка «пускать ли». + + Вынесена в свойство, чтобы вызывающий не писал `state == "active"`: добавится + четвёртое состояние — оно по умолчанию окажется «не пускать», а не «пускать, + потому что не disabled». + """ + return self is AccessState.ACTIVE + + +def to_access_state(value: object) -> AccessState: + """Приводит значение колонки `users.access_state` к `AccessState`. + + Fail-closed: неизвестная строка, NULL и любой неожиданный тип → `disabled` + + WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что новое + состояние, добавленное миграцией раньше кода, молча раздаёт доступ — а миграции + БД `auth` применяются деплоем «Птицы» (.forgejo/workflows/deploy.yml), то есть + опередить код они могут запросто. + """ + if isinstance(value, str): + try: + return AccessState(value) + except ValueError: + logger.warning( + "auth_session: неизвестное состояние доступа %r → трактую как disabled", value + ) + return AccessState.DISABLED + logger.warning( + "auth_session: состояние доступа %r неожиданного типа %s → трактую как disabled", + value, + type(value).__name__, + ) + return AccessState.DISABLED + + +@dataclass(frozen=True, slots=True) +class SessionUser: + """Кто стоит за валидной сессионной кукой. + + Attributes: + username: логин из реестра. Именно он, а не значение куки, дальше едет в + RBAC «Птицы» (`app.core.auth.get_role`). + access_state: всегда `AccessState.ACTIVE` — не-active сюда не доходит + (см. `get_session_user`). Поле оставлено явным, чтобы состояние доступа + во всём коде называлось и выражалось одинаково, а не превращалось в + неявное «раз объект вернулся, значит active». + """ + + username: str + access_state: AccessState + + +def get_session_user(db: Session, token: str) -> SessionUser | None: + """Резолвит сессионный токен в пользователя, или None если сессия невалидна. + + Невалидна = не найдена / истекла / состояние доступа юзера не `active`. + + Состояние доступа: пропускается ТОЛЬКО `AccessState.ACTIVE`. Любое другое + (`disabled`, `trial_expired`, а также нераспознанное — `to_access_state` + fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной + НЕМЕДЛЕННО, не дожидаясь `expires_at`. Иначе заблокированный человек продолжал + бы работать до истечения TTL (до 30 дней), а sliding-refresh продлевал бы ему + сессию бесконечно — то есть блокировка в реестре не блокировала бы ничего. + + Sliding refresh: если с последнего `last_seen_at` прошло >= 5 минут — продлевает + `last_seen_at`/`expires_at` ОДНИМ UPDATE (ровно как «Мера»: тот же интервал, тот + же одиночный UPDATE обеих колонок, тот же best-effort). Продлевать обе колонки + обязательно: обновляй «Птица» только `last_seen_at`, человек, работающий весь + день в ней одной, был бы разлогинен по `expires_at` несмотря на активность. + Сбой refresh (напр. read-only реплика) логируется и НЕ мешает вернуть валидного + юзера — это best-effort продление, а не часть решения «валидна ли сессия». + + Принимает уже открытую сессию БД `auth` (не открывает сам) — так модуль остаётся + тривиально unit-тестируемым. Обычный вызывающий берёт `resolve_session_token`. + + ⚠️ `db` ОБЯЗАНА быть сессией БД `auth` (`app.core.auth_db.auth_session()`), а не + `app.core.db.get_db`: в продуктовой БД gendesign таблиц `users`/`sessions` нет. + + Исключения БД наружу НЕ глушатся (кроме best-effort refresh): сбой реестра — + часть auth-решения, и вызывающий обязан его увидеть, чтобы закрыться, а не + трактовать как «сессии нет». + """ + if not token: + return None + + row = db.execute( + text( + """ + SELECT s.expires_at, s.last_seen_at, u.username, u.access_state + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token = :token + AND s.expires_at > now() + """ + ), + {"token": token}, + ).fetchone() + + if row is None: + return None + + now = datetime.now(UTC) + # Второй пояс к `AND s.expires_at > now()` в SELECT'е выше. Первый пояс — часами + # БД, и это принципиально: строку продлевает UPDATE ниже, где `expires_at = + # now() + interval` считает СЕРВЕР. Реши мы срок годности только часами процесса + # (`datetime.now(UTC)`), отставание этих часов давало бы не «сессия проживёт на + # дельту дольше», а НЕОБРАТИМОЕ воскрешение: строку, которую БД уже считает + # мёртвой, Python пропустил бы, тут же сработал бы sliding-refresh и отодвинул + # expires_at на полный TTL от серверного now(). Секунда расхождения → +30 дней. + # Обе стороны сравнения обязаны брать время из одного источника. + # + # Проверку на None оставляем первой: `expires_at` объявлен NOT NULL + # (data/sql/auth/001), но если колонку когда-нибудь ослабят, это дешевле + # разбирательства, почему сравнение с None упало TypeError'ом в auth-пути. + if row.expires_at is None or row.expires_at <= now: + return None + access_state = to_access_state(row.access_state) + if not access_state.can_sign_in: + return None + + if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL: + try: + db.execute( + text( + """ + UPDATE sessions + SET last_seen_at = now(), + expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer)) + WHERE token = :token + """ + ), + {"ttl_hours": settings.session_ttl_hours, "token": token}, + ) + db.commit() + except Exception: + # Без username в сообщении: строка лога — не место для связки + # «кто именно» + «в какой момент», а разбор всё равно идёт по времени. + logger.warning("auth_session: sliding refresh failed", exc_info=True) + try: + db.rollback() + except Exception: + # Причина сбоя UPDATE'а может быть оборванным соединением — тогда и + # rollback бросит. Без этого except «best-effort продление» переставало + # бы быть best-effort: валидный юзер, чью сессию не удалось продлить, + # получал бы не доступ, а исключение наружу (и в guard'е — деградацию + # на легаси-заголовок, а в db_only — отказ). + logger.warning("auth_session: rollback after failed refresh failed", exc_info=True) + + return SessionUser(username=row.username, access_state=access_state) + + +def resolve_session_token(token: str | None) -> SessionUser | None: + """Резолвит токен сессионной куки, сам открывая соединение с БД `auth`. + + Точка входа для `rbac_guard` (`app/main.py`), который зовёт её в threadpool — + внутри синхронный psycopg-I/O, а guard живёт на event loop'е. Возвращает None, + если сессии нет или она недействительна. + + Режим `legacy` (`AUTH_MODE=legacy`, ДЕФОЛТ) → None СРАЗУ, без единого + обращения к БД: инвариант «выключенный флаг = ни одного коннекта к реестру» + держится этим модулем, а не соглашением с вызывающим. Тихий None здесь безопасен, + потому что направлен в сторону fail-closed — он означает ровно «session-auth не + используется», то есть сегодняшнее поведение (Caddy basic_auth + trusted-header), + и никому ничего не открывает. + + Исключения НЕ глушатся — ни `AuthDatabaseNotConfiguredError` (флаг включён, DSN + пуст/битый), ни ошибки соединения. Решение «что делать со сломанным реестром» + принимает guard, и оно неочевидно: молча откатиться на trusted-header значит + раздавать права из roles.yaml в обход реестра, включая заблокированные аккаунты. + Прятать такое внутри резолвера нельзя. + + Raises: + AuthDatabaseNotConfiguredError: флаг включён, а DSN БД `auth` пуст или не + разобрался (см. `app.core.auth_db`). + """ + if not settings.auth_session_enabled: + return None + if not token: + return None + with auth_db.auth_session() as db: + return get_session_user(db, token) diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py new file mode 100644 index 00000000..3c02f7b9 --- /dev/null +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -0,0 +1,182 @@ +"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql). + +Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только +в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по +tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла +корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые +можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого +дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя. + +Тест не требует БД — только чтение файлов. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth" +_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap" +_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml" + +_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$") + +# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с +# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля, +# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL, +# значения проставляются на проде (прецедент: tradein м.193). +_SECRET_PATTERNS = ( + re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash + re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal' +) + + +def _auth_sql_files() -> list[Path]: + """Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT).""" + return sorted(_AUTH_SQL_DIR.glob("*.sql")) + + +def _secret_scanned_files() -> list[Path]: + """Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций. + + ⚠️ НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное. + + * data/sql/auth/*.sql — миграции: обязаны иметь имя NNN_snake_case.sql и обёртку + BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional). + * ops/db-bootstrap/*.sql — bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса + (порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции + (CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки + имён/BEGIN-COMMIT — значит сломать тест на корректных файлах. + + А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее: + единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` — это + ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды + «временно» вписать литерал вместо чтения из env. Другого контроля на это нет: + в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит), + а репо-wide grep невозможен — caddy/users.caddy.snippet легально содержит bcrypt-хеши + действующих логинов. + """ + return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")) + + +def test_scanned_dirs_are_not_empty() -> None: + """Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые). + + Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по + несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь + зелёными. Особенно опасно для проверки паролей — «зелено, потому что ничего не проверено». + """ + assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}" + assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}" + + +def test_filenames_and_unique_prefix() -> None: + """Имя вида NNN_snake_case.sql, префикс NNN уникален. + + Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают + неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий + свободный номер. + """ + seen: dict[str, str] = {} + bad_names: list[str] = [] + collisions: list[str] = [] + for path in _auth_sql_files(): + m = _FILENAME_RE.match(path.name) + if m is None: + bad_names.append(path.name) + continue + prefix = m.group(1) + if prefix in seen: + collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})") + else: + seen[prefix] = path.name + + assert not bad_names, ( + f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. " + "Порядок применения на проде определяется сортировкой имён." + ) + assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions) + + +def test_migrations_are_transactional() -> None: + """Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md). + + Red => частично применённая миграция оставит БД auth в промежуточном состоянии: + деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается. + """ + broken: list[str] = [] + for path in _auth_sql_files(): + text = path.read_text(encoding="utf-8") + statements = [ + line.strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("--") + ] + if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;": + broken.append(path.name) + assert ( + not broken + ), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)." + + +def test_no_concurrent_index_in_migrations() -> None: + """Ни одной CREATE/DROP INDEX CONCURRENTLY в data/sql/auth/*.sql. + + Red => миграция гарантированно падает на проде: CONCURRENTLY нельзя выполнять внутри + транзакционного блока (Postgres: 25001 «CREATE INDEX CONCURRENTLY cannot run inside a + transaction block»), а обёртка BEGIN;/COMMIT; здесь обязательна для всех файлов + (test_migrations_are_transactional). Две проверки по отдельности зелёные, а вместе + невыполнимые — поэтому запрет нужен явный: комбинация ловится только здесь. + Нужен CONCURRENTLY на большой таблице — это отдельный ручной прогон вне auto-apply, + а не файл в этом каталоге. + """ + hits: list[str] = [] + for path in _auth_sql_files(): + text = path.read_text(encoding="utf-8") + for line_no, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("--"): + continue # комментарий может объяснять запрет, не нарушая его + if re.search(r"\bCONCURRENTLY\b", line, re.IGNORECASE): + hits.append(f"{path.name}:{line_no}: {line.strip()}") + assert not hits, "CONCURRENTLY внутри BEGIN/COMMIT — упадёт на деплое: " + "; ".join(hits) + + +def test_no_password_material_in_auth_sql() -> None: + """Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей. + + Покрытие шире каталога миграций сознательно — обоснование в _secret_scanned_files(). + + Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL, + пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql + (значение приезжает из .env.runtime на VPS и в репозитории не существует). + """ + hits: list[str] = [] + for path in _secret_scanned_files(): + rel = path.relative_to(_REPO_ROOT).as_posix() + text = path.read_text(encoding="utf-8") + for line_no, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("--"): + continue # комментарии описывают запрет, а не нарушают его + for pattern in _SECRET_PATTERNS: + if pattern.search(line): + hits.append(f"{rel}:{line_no}: {line.strip()}") + assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits) + + +def test_deploy_workflow_applies_auth_migrations() -> None: + """deploy.yml реально прогоняет data/sql/auth/*.sql. + + Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql` + и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД + gendesign). Обратная сторона — без отдельного цикла в deploy.yml эти файлы не + применяются вообще и никто этого не заметит. Red => wiring удалён или переименован. + """ + workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8") + assert "data/sql/auth/*.sql" in workflow, ( + f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth " + "не применяются на деплое." + ) + assert ( + "ops/db-bootstrap/create_auth_db.sql" in workflow + ), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth." diff --git a/backend/tests/test_auth_db.py b/backend/tests/test_auth_db.py new file mode 100644 index 00000000..bb33762c --- /dev/null +++ b/backend/tests/test_auth_db.py @@ -0,0 +1,497 @@ +"""DSN и ленивый engine БД `auth` — `app/core/config.py` + `app/core/auth_db.py`. + +Эпик «единый вход», стадия 3. Три группы: + + 1. Дефолты. Они ЧАСТЬ КОНТРАКТА PR, а не декорация: пока Caddy basic_auth стоит, + прод обязан вести себя ровно как до эпика — флаг выключен, DSN не сконфигурирован, + engine не создаётся, отсутствие AUTH_* в окружении не роняет старт. + 2. Сборка DSN из частей: приоритет явного URL, экранирование секрета, пустые + значения переменных → прод-дефолты (а не мусорный DSN и не падение на импорте). + 3. `auth_db`: ленивость, кеш, внятная ошибка вместо утечки пароля. + +Сеть здесь не нужна: `create_engine` пул создаёт лениво и к серверу не ходит. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest +from pydantic import SecretStr, ValidationError +from sqlalchemy.engine import make_url + +from app.core import auth_db +from app.core.auth_db import AuthDatabaseNotConfiguredError, AuthDatabaseUnreachableError +from app.core.config import Settings, settings + +_AUTH_ENV_VARS = ( + "AUTH_MODE", + "AUTH_DATABASE_URL", + "AUTH_DB_PASSWORD", + "AUTH_DB_HOST", + "AUTH_DB_PORT", + "AUTH_DB_NAME", + "AUTH_DB_USER", + "SESSION_COOKIE_NAME", + "SESSION_TTL_HOURS", +) + +# Заведомо синтаксически корректный DSN на несуществующий хост: engine по нему +# создаётся, но соединение не открывается (пул ленивый), поэтому тесты офлайновы. +_OFFLINE_DSN = "postgresql+psycopg://auth_app:pw@127.0.0.1:1/auth" + + +@pytest.fixture +def clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одной AUTH_*/SESSION_* переменной — тест дефолтов не зависит от машины.""" + for name in _AUTH_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture(autouse=True) +def _reset_engine_cache() -> Iterator[None]: + """Ни один тест не оставляет за собой закешированный engine БД `auth`.""" + auth_db.reset_auth_db() + yield + auth_db.reset_auth_db() + + +# --------------------------------------------------------------------------- +# 1. Дефолты +# --------------------------------------------------------------------------- + + +def test_session_mode_is_off_and_unconfigured_by_default(clean_env: None) -> None: + """Свежие настройки без AUTH_*: режим legacy, DSN пуст — и это НЕ ошибка.""" + fresh = Settings() + + assert fresh.auth_mode == "legacy" + assert fresh.auth_session_enabled is False + assert fresh.resolved_auth_database_url == "" + + +def test_live_settings_singleton_is_off() -> None: + """Тот же инвариант на настоящем синглтоне, которым пользуется приложение.""" + assert settings.auth_mode == "legacy" + assert settings.auth_session_enabled is False + + +@pytest.mark.parametrize("raw", ["", " ", "LEGACY", " legacy "]) +def test_blank_or_odd_case_auth_mode_falls_back_to_legacy( + clean_env: None, monkeypatch: pytest.MonkeyPatch, raw: str +) -> None: + """`AUTH_MODE=` (или регистр/пробелы) → legacy, а не ValidationError на импорте. + + `settings = Settings()` выполняется на уровне модуля: невалидное значение уронило бы + ИМПОРТ конфига и увело контейнер в restart-loop. Сценарий бытовой — ops копирует + блок AUTH_* в .env.runtime и заполняет только пароль. + """ + monkeypatch.setenv("AUTH_MODE", raw) + + assert Settings().auth_mode == "legacy" + + +def test_meaningful_garbage_in_auth_mode_still_fails( + clean_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """`AUTH_MODE=off` — опечатка со смыслом, и она обязана падать. + + Молча трактовать её как legacy значило бы тихо оставить продукт на trusted-header + после того, как последний PR эпика снимет popup. + """ + monkeypatch.setenv("AUTH_MODE", "off") + + with pytest.raises(ValidationError): + Settings() + + +@pytest.mark.parametrize( + ("mode", "enabled"), [("legacy", False), ("dual", True), ("db_only", True)] +) +def test_auth_session_enabled_is_derived_from_mode( + clean_env: None, monkeypatch: pytest.MonkeyPatch, mode: str, enabled: bool +) -> None: + """Свойство `auth_session_enabled` = «режим не legacy» — единый выключатель реестра. + + Оно и закорачивает `resolve_session_token` / `require_auth_db_configured`; разница + dual vs db_only ему не видна и не должна быть (она про фолбэк в rbac_guard). + """ + monkeypatch.setenv("AUTH_MODE", mode) + + assert Settings().auth_session_enabled is enabled + + +def test_default_host_is_this_stacks_postgres(clean_env: None) -> None: + """🪤 Дефолт хоста — `postgres`, и это ЗЕРКАЛЬНО «Мере», а не копия с неё. + + У «Меры» дефолт `gendesign-postgres`, потому что внутри её стека имя `postgres` + занято её собственным контейнером. У «Птицы» наоборот: её стек главный, сервис + `postgres` корневого docker-compose.prod.yml и есть сервер с БД `auth`. Алиас + `gendesign-postgres` живёт только во внешней сети `shared`, куда входят не все + сервисы (beat — нет), поэтому дефолтом он быть не может. + """ + fresh = Settings() + + assert fresh.auth_db_host == "postgres" + assert fresh.auth_db_host != "gendesign-postgres" + assert fresh.auth_db_port == 5432 + assert fresh.auth_db_name == "auth" + assert fresh.auth_db_user == "auth_app" + + +def test_cookie_defaults_match_the_other_product(clean_env: None) -> None: + """Имя куки и TTL обязаны совпадать с «Мерой» — иначе общая сессия не общая. + + Имя историческое («tradein_» уже ни о чём не говорит); переименование + разлогинивает всех сразу в обоих продуктах, поэтому оно закреплено тестом. + """ + fresh = Settings() + + assert fresh.session_cookie_name == "tradein_session" + assert fresh.session_ttl_hours == 720 + + +# --------------------------------------------------------------------------- +# 2. Сборка DSN +# --------------------------------------------------------------------------- + + +def test_explicit_url_wins_over_parts(clean_env: None) -> None: + """AUTH_DATABASE_URL — аварийный обход (другой хост, sslmode): выигрывает всегда.""" + fresh = Settings( + auth_database_url=" postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require ", + auth_db_password=SecretStr("ignored"), + auth_db_host="postgres", + ) + + assert ( + fresh.resolved_auth_database_url + == "postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require" + ) + + +def test_dsn_built_from_password_and_defaults(clean_env: None) -> None: + """Включение на проде = одна переменная: пароль + прод-дефолты остальных частей.""" + fresh = Settings(auth_db_password=SecretStr("s3cret")) + + assert ( + fresh.resolved_auth_database_url + == "postgresql+psycopg://auth_app:s3cret@postgres:5432/auth" + ) + + +def test_password_special_chars_survive_round_trip(clean_env: None) -> None: + """Пароль экранируется: `@`/`/`/`:`/`#`/`%` иначе порвали бы URL по его грамматике. + + Проверяем не наличие процентов в строке, а РАЗБОР обратно: важно, что SQLAlchemy + видит тот же пароль и, главное, тот же хост/базу. Незакавыченный `@` молча увёл бы + подключение на другой хост. + """ + raw = "p@ss:w/rd#1%zz?x" + url = make_url(Settings(auth_db_password=SecretStr(raw)).resolved_auth_database_url) + + assert url.password == raw + assert url.host == "postgres" + assert url.port == 5432 + assert url.database == "auth" + assert url.username == "auth_app" + + +def test_username_is_quoted_too(clean_env: None) -> None: + url = make_url( + Settings( + auth_db_password=SecretStr("pw"), auth_db_user="odd:user@name" + ).resolved_auth_database_url + ) + + assert url.username == "odd:user@name" + assert url.host == "postgres" + + +def test_password_whitespace_is_preserved_not_stripped(clean_env: None) -> None: + """Ведущий/хвостовой пробел может быть частью настоящего пароля — не режем.""" + url = make_url(Settings(auth_db_password=SecretStr(" pw ")).resolved_auth_database_url) + + assert url.password == " pw " + + +def test_blank_password_means_not_configured(clean_env: None) -> None: + """Пробельная строка — опечатка в .env, а не пароль: «не сконфигурировано».""" + assert Settings(auth_db_password=SecretStr(" ")).resolved_auth_database_url == "" + assert Settings(auth_db_password=SecretStr("")).resolved_auth_database_url == "" + + +def test_blank_parts_fall_back_to_defaults(clean_env: None) -> None: + """`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`. + + Сценарий бытовой: ops копирует блок AUTH_DB_* целиком и заполняет только пароль. + """ + url = make_url( + Settings( + auth_db_password=SecretStr("pw"), + auth_db_host=" ", + auth_db_name="", + auth_db_user=" ", + ).resolved_auth_database_url + ) + + assert (url.host, url.database, url.username) == ("postgres", "auth", "auth_app") + + +@pytest.mark.parametrize("blank", ["", " "]) +def test_blank_port_does_not_break_import(clean_env: None, blank: str) -> None: + """`AUTH_DB_PORT=` → дефолт, а НЕ ValidationError. + + `settings = Settings()` выполняется на уровне модуля: падение здесь уводило бы + контейнер в restart-loop — причём в дефолтном режиме, где к БД `auth` не идёт ни + одного обращения. + """ + assert Settings(auth_db_port=blank).auth_db_port == 5432 + + +def test_non_blank_garbage_port_still_fails(clean_env: None) -> None: + """`AUTH_DB_PORT=abc` — опечатка со смыслом, её глушить нельзя.""" + with pytest.raises(ValueError): + Settings(auth_db_port="abc") + + +def test_password_is_not_printed_by_repr_or_dump(clean_env: None) -> None: + """SecretStr: пароль не утекает в `repr(settings)` / `model_dump()`. + + Сегодня их никто не рендерит, но появиться такой рендер (лог старта, /debug) может + тихо — а рядом с обычным str-полем это была бы утечка секрета в открытый лог. + """ + fresh = Settings(auth_db_password=SecretStr("s3cret")) + + assert "s3cret" not in repr(fresh) + assert "s3cret" not in str(fresh.model_dump()) + assert fresh.auth_db_password.get_secret_value() == "s3cret" + + +# --------------------------------------------------------------------------- +# 3. auth_db: ленивость, кеш, ошибки +# --------------------------------------------------------------------------- + + +def test_import_without_any_auth_env_does_not_build_engine() -> None: + """Импорт в чистом окружении: ошибки нет, engine не создан, DSN пуст. + + Проверяется отдельным процессом, потому что в текущем модуль импортирован давно и + любое утверждение про «на импорте» было бы про уже случившийся импорт. Это отличие + от `app.core.db`, где engine создаётся в теле модуля: сделай мы так же, приложение + падало бы на старте везде, где реестр не сконфигурирован — локально, в pytest, на + любом стенде. Ровно тот контракт, который держит дефолтное поведение прода. + """ + env = {k: v for k, v in os.environ.items() if k not in _AUTH_ENV_VARS} + code = ( + "from app.core import auth_db\n" + "from app.core.config import settings\n" + "print(auth_db._engine, repr(settings.resolved_auth_database_url), " + "settings.auth_session_enabled)\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + cwd=Path(__file__).resolve().parents[1], + env=env, + capture_output=True, + text=True, + ) + + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "None '' False" + + +def test_unconfigured_registry_raises_with_actionable_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пустой DSN → явная ошибка с инструкцией, а не «сессия не найдена».""" + monkeypatch.setattr(settings, "auth_database_url", "") + monkeypatch.setattr(settings, "auth_db_password", SecretStr("")) + + with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo: + auth_db.get_auth_engine() + + assert "AUTH_MODE" in str(excinfo.value) + assert "AUTH_DB_PASSWORD" in str(excinfo.value) + + +@pytest.mark.parametrize( + "broken", + [ + "not-a-dsn-at-all", + # «Почти URL»: разбор доходит до int(port) и падает, унося в текст ошибки + # кусок пароля, съехавший на позицию порта. + "postgresql+psycopg://u:pa@ss@host:wo/auth", + ], +) +def test_malformed_dsn_does_not_leak_into_the_error( + monkeypatch: pytest.MonkeyPatch, broken: str +) -> None: + """Битый DSN → своя ошибка БЕЗ самого DSN и без исходного traceback. + + Текст ошибки SQLAlchemy цитирует строку целиком, а в ней пароль роли auth_app. + `from None` обязателен: без него исходная ошибка печаталась бы в traceback как + «During handling of the above exception...» — то есть пароль всё равно оказался бы + в логе. + """ + monkeypatch.setattr(settings, "auth_database_url", broken) + + with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo: + auth_db.get_auth_engine() + + assert broken not in str(excinfo.value) + assert "pa@ss" not in str(excinfo.value) + assert excinfo.value.__suppress_context__ is True + assert excinfo.value.__cause__ is None + + +def test_engine_is_built_once_and_reused(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN) + + first = auth_db.get_auth_engine() + second = auth_db.get_auth_engine() + + assert first is second + assert auth_db.get_auth_session_factory().kw["bind"] is first + assert first.url.database == "auth" + + +def test_reset_drops_the_cached_engine(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN) + first = auth_db.get_auth_engine() + + auth_db.reset_auth_db() + + assert auth_db._engine is None + assert auth_db.get_auth_engine() is not first + + +# --------------------------------------------------------------------------- +# require_auth_db_configured — fail-fast на старте (lifespan) +# --------------------------------------------------------------------------- + + +def test_startup_check_is_noop_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None: + """🔒 Дефолт: пустой DSN на старте — не ошибка, и engine не создаётся. + + Ровно то, что произойдёт на проде сразу после мержа этого PR. + """ + monkeypatch.setattr(settings, "auth_mode", "legacy") + monkeypatch.setattr(settings, "auth_database_url", "") + monkeypatch.setattr(settings, "auth_db_password", SecretStr("")) + + auth_db.require_auth_db_configured() + + assert auth_db._engine is None + + +def test_startup_check_fails_fast_when_enabled_without_dsn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Флаг включили, DSN не задали → контейнер не стартует. + + Иначе пустой DSN ловил бы `except` в guard'е, и сломанная конфигурация выглядела бы + как «ни у кого нет сессии» — сутками, при живом приложении и 200-х в ответах. + """ + monkeypatch.setattr(settings, "auth_mode", "dual") + monkeypatch.setattr(settings, "auth_database_url", "") + monkeypatch.setattr(settings, "auth_db_password", SecretStr("")) + + with pytest.raises(AuthDatabaseNotConfiguredError): + auth_db.require_auth_db_configured() + + +def test_startup_check_builds_engine_and_probes_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Режим включён и реестр отвечает → engine готов ещё до первого запроса. + + Проба соединения подменена: поднимать Postgres ради этого теста незачем, важно, что + она вызывается ИМЕННО на том engine, который останется закешированным. + """ + monkeypatch.setattr(settings, "auth_mode", "dual") + monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN) + probed: list[object] = [] + monkeypatch.setattr(auth_db, "_probe_connection", probed.append) + + auth_db.require_auth_db_configured() + + assert auth_db._engine is not None + assert probed == [auth_db._engine] + + +def test_startup_check_fails_when_dsn_parses_but_connection_does_not( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """🔴 Смысл проверки: DSN разобрался — это ещё НЕ «реестр рабочий». + + `create_engine` к серверу не ходит, поэтому одна лишь сборка engine отлавливала бы + ровно два случая (DSN пуст / не парсится). Весь вероятный класс ошибок — неверный + AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная роль + auth_app, нет сети — проходил бы мимо, контейнер стартовал бы зелёным, `/health` + отвечал бы 200, а каждый запрос с кукой молча деградировал бы на легаси-заголовок. + Сутками. Ровно то, что комментарий в app/main.py обещает НЕ допускать. + """ + monkeypatch.setattr(settings, "auth_mode", "dual") + monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN) + + def _refused(_engine: object) -> None: + raise OSError("connection to server at 127.0.0.1, port 1 failed: Connection refused") + + monkeypatch.setattr(auth_db, "_probe_connection", _refused) + + with pytest.raises(AuthDatabaseUnreachableError) as excinfo: + auth_db.require_auth_db_configured() + + # Причина сохранена в цепочке — ради неё проверка и делается; DSN (в нём пароль) в + # наш текст не подставляется. + assert isinstance(excinfo.value.__cause__, OSError) + assert "AUTH_MODE" in str(excinfo.value) + assert _OFFLINE_DSN not in str(excinfo.value) + + +def test_startup_check_does_not_probe_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None: + """🔒 legacy: ни коннекта, ни пробы — даже если DSN задан и валиден.""" + monkeypatch.setattr(settings, "auth_mode", "legacy") + monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN) + + def _boom(_engine: object) -> None: + raise AssertionError("в режиме legacy соединение с реестром недопустимо") + + monkeypatch.setattr(auth_db, "_probe_connection", _boom) + + auth_db.require_auth_db_configured() + + assert auth_db._engine is None + + +def test_engine_has_short_timeouts(monkeypatch: pytest.MonkeyPatch) -> None: + """Реестр — не критический путь: его сбой обязан деградировать за секунды. + + Без `connect_timeout` дропнутые SYN (фаервол молча глотает пакеты) держали бы + попытку до TCP-таймаута ОС — на Linux ~130 с, и так на КАЖДОМ checkout'е, потому + что включён `pool_pre_ping`. `pool_timeout` по дефолту 30 с — в auth-пути столько + ждать свободный коннект незачем. + """ + monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN) + captured: dict[str, object] = {} + real_create_engine = auth_db.create_engine + + def _spy(dsn: str, **kwargs: object) -> object: + captured.update(kwargs) + return real_create_engine(dsn, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(auth_db, "create_engine", _spy) + + auth_db.get_auth_engine() + + assert captured["pool_timeout"] == 3 + assert captured["pool_pre_ping"] is True + connect_args = captured["connect_args"] + assert isinstance(connect_args, dict) + assert connect_args["connect_timeout"] == 3 + assert "statement_timeout=3000" in connect_args["options"] diff --git a/backend/tests/test_auth_session_guard.py b/backend/tests/test_auth_session_guard.py new file mode 100644 index 00000000..0784af09 --- /dev/null +++ b/backend/tests/test_auth_session_guard.py @@ -0,0 +1,861 @@ +"""Dual-mode `rbac_guard` «Птицы» — эпик «единый вход», стадия 3 (тесты). + +Что здесь проверяется и почему именно так. + +ТЕСТИРУЕТСЯ НАСТОЯЩИЙ `app.main.rbac_guard`, а не его копия. `app.middleware("http")` +у Starlette возвращает саму функцию (декоратор регистрирует dispatch и отдаёт `func`), +поэтому middleware вызывается напрямую: `await rbac_guard(request, call_next)`. Это +принципиально — в отличие от `tests/test_rbac.py`, где живёт РУЧНАЯ КОПИЯ guard'а +(она заведена, чтобы не тянуть тяжёлые импорты, и ценой этого расходится с прод-кодом +при каждой правке). Главный тест этого файла — про подделку заголовка, то есть про +безопасность; проверять безопасность на копии нельзя, копия не деплоится. + +Почему не через `TestClient(app)`: `rbac_guard` первой строкой уходит в +test-mode bypass при `settings.testing=True`, а conftest.py ставит этот флаг +глобально (иначе весь остальной сьют получал бы 401). Прямой вызов middleware +позволяет снять именно этот флаг (monkeypatch, см. `_no_test_bypass`) и получить +прод-поведение guard'а целиком: и session-ветку, и легаси-ветку, и 401/403. + +Как проверяется «downstream видит нужного юзера». `_propagate_authenticated_user` +перезаписывает заголовок в `request.scope["headers"]`; scope прокинут по ссылке через +весь ASGI-стек, и следующий слой (audit-middleware, роутер) строит поверх него СВОЙ +`Request`. Дублёр `_Downstream` делает ровно это — `Request(request.scope)` — то есть +видит заголовок так же, как одиннадцать мест бэкенда, читающих его напрямую мимо +guard'а (перечислены в докстринге `_propagate_authenticated_user`). + +БД `auth` здесь не поднимается: подменяется `app.core.auth_db.auth_session` (сам резолв +сессии — `get_session_user` — прогоняется НАСТОЯЩИЙ, чтобы «истекла»/«не active» +проверялись кодом, а не заглушкой). Юнит-тесты самого резолва — в +`tests/test_auth_session_service.py`, конфигурация DSN — в `tests/test_auth_db.py`. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from fastapi import Request +from fastapi.responses import JSONResponse, Response + +import app.main as app_main +from app.core import auth as auth_mod +from app.core import auth_db +from app.core.config import settings +from app.main import rbac_guard + +# Логины из auth/roles.yaml (см. tests/test_rbac.py::test_get_role_known_users): +_ADMIN_LOGIN = "admin" # role=admin +_PILOT_LOGIN = "user1" # role=pilot +_NOT_IN_ROLES_YAML = "ghost" # роли нет вообще → 403 на всё + +_VALID_TOKEN = "tok-valid" +_EXPIRED_TOKEN = "tok-expired" +_UNKNOWN_TOKEN = "tok-never-issued" + + +# --------------------------------------------------------------------------- +# Дублёры +# --------------------------------------------------------------------------- + + +@dataclass +class _Row: + """Строка ответа SELECT'а из `app/services/auth_session.py` (4 колонки).""" + + expires_at: datetime | None + last_seen_at: datetime | None + username: str + access_state: str + + +class _FetchOne: + def __init__(self, row: _Row | None) -> None: + self._row = row + + def fetchone(self) -> _Row | None: + return self._row + + +# Форма запросов к реестру. Без этих проверок дублёр диспетчеризует по одному лишь +# `startswith`, и тела SQL не покрыты ВООБЩЕ: мутационный прогон показал, что +# `sessions`→`sessionz`, `users`→`userz`, `s.token`→`s.tokenX`, `last_seen_at`→ +# `last_seen_atX` не роняли ни одного теста. Настоящего Postgres в сьюте нет, а цена +# опечатки/дрейфа схемы здесь высокая: не 500, а «ни у кого нет сессии» с тихим +# откатом на легаси-заголовок (после снятия popup'а — локаут всех). +_SELECT_MUST_CONTAIN = ( + "FROM sessions s", + "JOIN users u ON u.id = s.user_id", + "WHERE s.token = :token", + # Срок годности отсекается часами БД — теми же, которыми UPDATE ниже пишет + # expires_at. Питоновская проверка остаётся вторым поясом. + "AND s.expires_at > now()", + "s.expires_at", + "s.last_seen_at", + "u.username", + "u.access_state", +) +_UPDATE_MUST_CONTAIN = ( + "UPDATE sessions", + "last_seen_at = now()", + # Обе колонки одним UPDATE: продлевай «Птица» только last_seen_at — человек, + # работающий весь день в ней одной, был бы разлогинен по expires_at. + "expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))", + "WHERE token = :token", +) + + +def _assert_select_shape(sql: str) -> None: + for fragment in _SELECT_MUST_CONTAIN: + assert fragment in sql, f"SELECT к БД auth потерял {fragment!r}: {sql}" + + +def _assert_update_shape(sql: str) -> None: + for fragment in _UPDATE_MUST_CONTAIN: + assert fragment in sql, f"UPDATE к БД auth потерял {fragment!r}: {sql}" + + +class FakeAuthDb: + """Дублёр сессии SQLAlchemy к БД `auth`: понимает ровно два запроса модуля. + + Считает обращения (`select_tokens`, `updates`) — по ним тесты доказывают не только + результат, но и что запрос вообще был/не был сделан. + """ + + def __init__(self, rows: dict[str, _Row] | None = None, *, fail_refresh: bool = False) -> None: + self.rows: dict[str, _Row] = dict(rows or {}) + self.select_tokens: list[str] = [] + self.updates: list[dict[str, Any]] = [] + self.commits = 0 + self.rollbacks = 0 + self.closed = False + self.fail_refresh = fail_refresh + + def execute(self, clause: Any, params: dict[str, Any]) -> _FetchOne: + sql = " ".join(str(clause).split()) + if sql.startswith("SELECT"): + _assert_select_shape(sql) + self.select_tokens.append(params["token"]) + return _FetchOne(self.rows.get(params["token"])) + if sql.startswith("UPDATE sessions"): + _assert_update_shape(sql) + if self.fail_refresh: + raise RuntimeError("sessions is read-only on this replica") + self.updates.append(dict(params)) + return _FetchOne(None) + raise AssertionError(f"неожиданный SQL к БД auth: {sql}") + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rollbacks += 1 + + +def _install_auth_db(monkeypatch: pytest.MonkeyPatch, db: FakeAuthDb | None) -> None: + """Подменяет `auth_db.auth_session`. `db=None` → любое обращение к БД падает.""" + + @contextmanager + def _fake_session() -> Iterator[FakeAuthDb]: + if db is None: + raise RuntimeError("connection to auth registry refused") + yield db + + monkeypatch.setattr(auth_db, "auth_session", _fake_session) + + +class _Downstream: + """`call_next`: запоминает, каким юзером запрос выглядит для следующего слоя.""" + + def __init__(self) -> None: + self.calls = 0 + self.seen_users: list[str | None] = [] + self.seen_header_counts: list[int] = [] + + async def __call__(self, request: Request) -> Response: + # Именно так заголовок видят 11 прямых читателей: свой Request поверх того же + # scope, который guard уже успел переписать. + downstream = Request(request.scope) + self.calls += 1 + self.seen_users.append(downstream.headers.get("X-Authenticated-User")) + self.seen_header_counts.append( + sum(1 for k, _ in request.scope["headers"] if k == b"x-authenticated-user") + ) + return JSONResponse({"ok": True}) + + +def _make_request( + path: str, + *, + cookie_token: str | None = None, + header_user: str | None = None, + cookie_name: str | None = None, + header_name: bytes = b"x-authenticated-user", +) -> Request: + """ASGI-scope запроса. Имена заголовков lowercase — как их отдаёт любой сервер. + + `header_name` позволяет подсунуть имя в НЕканоническом регистре: спека ASGI требует + lowercase, но полагаться на неё в фильтре `_propagate_authenticated_user` мы не + хотим (чужой ASGI-слой/харнесс может её нарушить). + """ + headers: list[tuple[bytes, bytes]] = [(b"host", b"gendsgn.ru")] + if cookie_token is not None: + name = cookie_name or settings.session_cookie_name + headers.append((b"cookie", f"{name}={cookie_token}".encode())) + if header_user is not None: + headers.append((header_name, header_user.encode("latin-1"))) + return Request( + { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "GET", + "scheme": "https", + "server": ("gendsgn.ru", 443), + "client": ("203.0.113.7", 51234), + "root_path": "", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": headers, + } + ) + + +async def _run_guard(request: Request) -> tuple[Response, _Downstream]: + downstream = _Downstream() + response = await rbac_guard(request, downstream) + return response, downstream + + +def _valid_session(username: str, *, access_state: str = "active") -> _Row: + now = datetime.now(UTC) + return _Row( + expires_at=now + timedelta(days=7), + last_seen_at=now - timedelta(seconds=30), # свежее 5 минут → без UPDATE + username=username, + access_state=access_state, + ) + + +# --------------------------------------------------------------------------- +# Фикстуры +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_auth_cache() -> None: + """Свежий YAML-кэш ролей на каждый тест (как в tests/test_rbac.py).""" + auth_mod.reset_cache_for_tests() + + +@pytest.fixture(autouse=True) +def _reset_registry_throttle() -> Iterator[None]: + """Окно троттлинга алерта «реестр не отвечает» — модульное состояние app.main. + + Без сброса первый же тест, поймавший сбой реестра, глушил бы ERROR у всех + следующих в течение минуты, и они краснели/зеленели бы в зависимости от порядка + и скорости прогона. + """ + app_main._reset_registry_failure_throttle() + yield + app_main._reset_registry_failure_throttle() + + +@pytest.fixture(autouse=True) +def _no_test_bypass(monkeypatch: pytest.MonkeyPatch) -> None: + """Снимает test-mode bypass: без этого guard возвращает call_next первой строкой. + + conftest.py ставит `settings.testing = True` глобально; monkeypatch вернёт его + обратно после каждого теста, так что остальной сьют не затронут. + """ + monkeypatch.setattr(settings, "testing", False) + + +@pytest.fixture +def resolve_spy(monkeypatch: pytest.MonkeyPatch) -> list[str | None]: + """Считает вызовы `resolve_session_token` из app.main, не подменяя его логику. + + Нужен, чтобы доказывать НЕ-обращения: «флаг выключен → к реестру не ходим», + «публичный путь → к реестру не ходим». + """ + calls: list[str | None] = [] + real = app_main.resolve_session_token + + def _spy(token: str | None) -> Any: + calls.append(token) + return real(token) + + monkeypatch.setattr(app_main, "resolve_session_token", _spy) + return calls + + +@pytest.fixture +def no_engine_build(monkeypatch: pytest.MonkeyPatch) -> None: + """Ломает создание engine БД `auth`: тест покраснеет, если его вообще строят.""" + + def _boom() -> tuple[Any, Any]: + raise AssertionError("engine БД `auth` не должен создаваться в этом сценарии") + + monkeypatch.setattr(auth_db, "_build", _boom) + + +# --------------------------------------------------------------------------- +# ФЛАГ ВЫКЛЮЧЕН (дефолт) — прод обязан вести себя ровно как до эпика +# --------------------------------------------------------------------------- + + +async def test_flag_is_off_by_default() -> None: + """Дефолт синглтона settings — режим legacy. Весь файл ниже опирается на это.""" + assert settings.auth_mode == "legacy" + assert settings.auth_session_enabled is False + + +async def test_flag_off_legacy_header_still_works( + monkeypatch: pytest.MonkeyPatch, resolve_spy: list[str | None], no_engine_build: None +) -> None: + """Сегодняшний путь (Caddy basic_auth → X-Authenticated-User) не изменился.""" + monkeypatch.setattr(settings, "auth_mode", "legacy") + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN)) + + assert response.status_code == 200 + assert downstream.seen_users == [_ADMIN_LOGIN] + assert resolve_spy == [] + + +async def test_flag_off_ignores_session_cookie_and_never_touches_registry( + monkeypatch: pytest.MonkeyPatch, resolve_spy: list[str | None], no_engine_build: None +) -> None: + """🔒 Инвариант PR: при выключенном флаге кука не читается, к БД `auth` не идём. + + Валидная кука + нет легаси-заголовка → 401, как сегодня у любого запроса мимо + Caddy. `resolve_spy`/`no_engine_build` доказывают, что дело не в «не нашли + сессию», а в том, что резолв вообще не запускался и engine не строился. + """ + monkeypatch.setattr(settings, "auth_mode", "legacy") + _install_auth_db(monkeypatch, FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)})) + + response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)) + + assert response.status_code == 401 + assert downstream.calls == 0 + assert resolve_spy == [] + + +async def test_flag_off_unknown_user_still_403(monkeypatch: pytest.MonkeyPatch) -> None: + """Легаси-ветка целиком: юзер не в roles.yaml → 403 «user not in roles config».""" + monkeypatch.setattr(settings, "auth_mode", "legacy") + + response, downstream = await _run_guard( + _make_request("/api/v1/me", header_user=_NOT_IN_ROLES_YAML) + ) + + assert response.status_code == 403 + assert downstream.calls == 0 + + +# --------------------------------------------------------------------------- +# ФЛАГ ВКЛЮЧЁН — сессионная кука как источник личности +# --------------------------------------------------------------------------- + + +@pytest.fixture +def session_on(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "auth_mode", "dual") + + +async def test_valid_cookie_grants_access_without_any_header( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """Валидная кука пускает — легаси-заголовка при этом нет вовсе.""" + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)) + + assert response.status_code == 200 + assert downstream.seen_users == [_PILOT_LOGIN] + assert db.select_tokens == [_VALID_TOKEN] + + +async def test_expired_session_cookie_does_not_grant_access( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """Истёкшая сессия = сессии нет: без легаси-заголовка это 401.""" + now = datetime.now(UTC) + db = FakeAuthDb( + { + _EXPIRED_TOKEN: _Row( + expires_at=now - timedelta(seconds=1), + last_seen_at=now - timedelta(days=1), + username=_ADMIN_LOGIN, + access_state="active", + ) + } + ) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_EXPIRED_TOKEN) + ) + + assert response.status_code == 401 + assert downstream.calls == 0 + # Истёкшая сессия не продлевается sliding-refresh'ем — иначе она была бы вечной. + assert db.updates == [] + + +@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "some_future_state"]) +async def test_non_active_access_state_does_not_grant_access( + monkeypatch: pytest.MonkeyPatch, session_on: None, access_state: str +) -> None: + """Блокировка в реестре действует НЕМЕДЛЕННО, не дожидаясь expires_at. + + `some_future_state` — состояние, добавленное миграцией раньше кода: fail-closed + (`to_access_state` → disabled), а не «раз не disabled, значит пускаем». + """ + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN, access_state=access_state)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)) + + assert response.status_code == 401 + assert downstream.calls == 0 + assert db.updates == [] + + +async def test_session_user_missing_from_roles_yaml_is_403( + monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture +) -> None: + """Есть в реестре, нет в auth/roles.yaml → 403 + отдельное сообщение о рассинхроне. + + Реестр отвечает «кто ты», roles.yaml — «что тебе можно»; человек, заведённый только + в реестре, не получает доступ по умолчанию. + """ + db = FakeAuthDb({_VALID_TOKEN: _valid_session("brand_new_hire")}) + _install_auth_db(monkeypatch, db) + + with caplog.at_level(logging.WARNING, logger="app.main"): + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN) + ) + + assert response.status_code == 403 + assert downstream.calls == 0 + assert any("roles.yaml" in r.getMessage() for r in caplog.records) + + +# --------------------------------------------------------------------------- +# 🔴 ГЛАВНОЕ: подделка X-Authenticated-User при валидной куке +# --------------------------------------------------------------------------- + + +async def test_valid_cookie_overrides_client_supplied_header( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """🔴 Кука выигрывает у присланного клиентом заголовка — downstream видит ВЛАДЕЛЬЦА КУКИ. + + Сценарий: у человека есть валидная сессия (`user1`, pilot), и он вручную добавляет + к запросу `X-Authenticated-User: admin`. На проде Caddy шлёт этот заголовок на + каждый запрос, так что «поставить только если отсутствует» здесь не сработало бы: + заголовок присутствует ВСЕГДА, и любой из одиннадцати прямых читателей (аудит, + /me, created_by в insights/own-projects, forecast/analyze) увидел бы подделку. + + Проверяем оба следствия перезаписи: значение — владелец куки, и заголовок в scope + РОВНО ОДИН (append без фильтра оставил бы два, а `headers.get` вернул бы первый — + то есть подделанный). + """ + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN) + ) + + assert response.status_code == 200 + assert downstream.seen_users == [_PILOT_LOGIN], "downstream увидел подделанный заголовок" + assert downstream.seen_header_counts == [1], "в scope осталось два X-Authenticated-User" + + +async def test_forged_admin_header_cannot_escalate_to_admin_api( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """🔴 Та же подделка на admin-эндпоинте: роль берётся от владельца куки → 403. + + Это тест на ЭСКАЛАЦИЮ ПРИВИЛЕГИЙ, а не на атрибуцию, и он ловит другую поломку, + чем тест выше. Проверено мутацией: подмена перезаписи заголовка на append его НЕ + красит — guard решает по локальной переменной `username`, уже взятой из сессии. + Покраснеет он от поломки ПОРЯДКА: «сначала заголовок, потом кука» или повторное + чтение `request.headers` после резолва — тогда pilot с подделанным `admin` вошёл + бы в /api/v1/admin/*. Оба теста нужны: один держит downstream, другой — сам guard. + """ + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard( + _make_request( + "/api/v1/admin/scrape/status", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN + ) + ) + + assert response.status_code == 403 + assert response.body == b'{"detail":"admin only"}' + assert downstream.calls == 0 + + +async def test_cookie_owner_wins_even_when_forged_header_is_unknown_user( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """Обратная сторона: мусор в заголовке не мешает владельцу валидной куки войти. + + Пинует порядок «кука → заголовок»: если бы заголовок проверялся первым, `ghost` + дал бы 403 человеку с законной сессией. + """ + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard( + _make_request( + "/api/v1/admin/scrape/status", + cookie_token=_VALID_TOKEN, + header_user=_NOT_IN_ROLES_YAML, + ) + ) + + assert response.status_code == 200 + assert downstream.seen_users == [_ADMIN_LOGIN] + + +# --------------------------------------------------------------------------- +# Dual-mode: нет куки / кука не резолвится → легаси-заголовок +# --------------------------------------------------------------------------- + + +async def test_no_cookie_falls_back_to_legacy_header( + monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None] +) -> None: + """Флаг включён, куки нет — работает заголовок, и в БД `auth` не идёт ни запроса.""" + _install_auth_db(monkeypatch, None) # любое обращение к реестру → RuntimeError + + response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN)) + + assert response.status_code == 200 + assert downstream.seen_users == [_ADMIN_LOGIN] + assert resolve_spy == [], "куки нет — резолвить нечего, коннект открывать незачем" + + +async def test_unknown_token_falls_back_to_legacy_header( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """Кука есть, сессии в реестре нет (протухла/отозвана) → легаси-путь, не отказ. + + Пока стоит popup, это ровно тот же уровень доступа, что и сегодня; отказывать + здесь значило бы ломать вход людям со старой кукой в браузере. + """ + db = FakeAuthDb() # пусто: токен не найден + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_UNKNOWN_TOKEN, header_user=_ADMIN_LOGIN) + ) + + assert response.status_code == 200 + assert downstream.seen_users == [_ADMIN_LOGIN] + assert db.select_tokens == [_UNKNOWN_TOKEN] + + +async def test_foreign_cookie_name_is_not_a_session( + monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None] +) -> None: + """Чужая кука (другое имя) сессией не считается — читаем только session_cookie_name.""" + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token="whatever", cookie_name="ym_uid") + ) + + assert response.status_code == 401 + assert downstream.calls == 0 + assert resolve_spy == [] + + +async def test_no_cookie_no_header_is_401( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """Ни куки, ни заголовка → 401 с прежним текстом (его читает фронт).""" + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard(_make_request("/api/v1/me")) + + assert response.status_code == 401 + assert b"no authenticated user" in response.body + assert downstream.calls == 0 + + +# --------------------------------------------------------------------------- +# Публичные пути +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", sorted(app_main._PUBLIC_PATHS)) +async def test_public_paths_need_nothing_and_touch_no_registry( + monkeypatch: pytest.MonkeyPatch, + session_on: None, + resolve_spy: list[str | None], + no_engine_build: None, + path: str, +) -> None: + """/health и прочие публичные пути — без куки, без заголовка, без коннекта к `auth`. + + Параметризация по самому `_PUBLIC_PATHS`: добавят путь в список — он проверится. + """ + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard(_make_request(path)) + + assert response.status_code == 200 + assert downstream.calls == 1 + assert resolve_spy == [] + + +async def test_public_path_with_cookie_still_skips_registry( + monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None] +) -> None: + """Публичный путь + кука в браузере → всё равно ни одного запроса к реестру.""" + _install_auth_db(monkeypatch, FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)})) + + response, _ = await _run_guard(_make_request("/health", cookie_token=_VALID_TOKEN)) + + assert response.status_code == 200 + assert resolve_spy == [] + + +# --------------------------------------------------------------------------- +# Сбой БД `auth` при резолве +# --------------------------------------------------------------------------- + + +async def test_registry_failure_does_not_silently_admit_cookie_owner( + monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture +) -> None: + """Реестр упал → 401 (нет легаси-заголовка) + ERROR с traceback, а не тихий проход. + + Два независимых требования: + 1) владелец куки НЕ входит «на всякий случай» — упавший резолв не даёт личности; + 2) событие громкое: `logger.exception` уровня ERROR уезжает в GlitchTip + (LoggingIntegration event_level=ERROR), т.е. это алерт, а не строка в логе. + """ + _install_auth_db(monkeypatch, None) + + with caplog.at_level(logging.ERROR, logger="app.main"): + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN) + ) + + assert response.status_code == 401 + assert downstream.calls == 0 + errors = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert len(errors) == 1, "сбой реестра обязан быть ровно одним ERROR-событием" + assert errors[0].exc_info is not None, "нужен traceback: без него алерт бесполезен" + + +async def test_registry_failure_degrades_to_legacy_while_popup_is_up( + monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture +) -> None: + """Осознанная переходная деградация: сломанный реестр → сегодняшний путь + ERROR. + + Пока Caddy basic_auth стоит перед бэкендом, легаси-заголовок защищён ровно тем же, + чем защищён весь продукт сегодня, и класть «Птицу» целиком (503) из-за проблемы + реестра незачем. + + ⚠️ Этот тест — маркер долга, а не одобрение поведения навсегда. Последний PR эпика + снимает popup; вместе с ним деградация обязана уйти (у «Меры» это auth_mode=db_only), + иначе заголовок станет полностью клиентским. Тест тогда переписывается на отказ. + """ + _install_auth_db(monkeypatch, None) + + with caplog.at_level(logging.ERROR, logger="app.main"): + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN) + ) + + assert response.status_code == 200 + assert downstream.seen_users == [_ADMIN_LOGIN] + assert [r for r in caplog.records if r.levelno >= logging.ERROR] + + +# --------------------------------------------------------------------------- +# Test-mode bypass остаётся выключателем ВСЕГО guard'а +# --------------------------------------------------------------------------- + + +async def test_testing_bypass_disables_session_branch_too( + monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None] +) -> None: + """`settings.testing=True` отключает и session-ветку — сознательно, не по недосмотру. + + Промежуточного состояния «личность резолвим, а 401/403 не применяем» нет ни в одном + реальном режиме; поэтому весь остальной сьют (conftest ставит testing=True) не + начинает вдруг ходить в БД `auth`. + """ + monkeypatch.setattr(settings, "testing", True) + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN) + ) + + assert response.status_code == 200 + assert downstream.seen_users == [_ADMIN_LOGIN], "bypass не должен переписывать заголовок" + assert resolve_spy == [] + + +async def test_forged_header_in_mixed_case_is_replaced_not_duplicated( + monkeypatch: pytest.MonkeyPatch, session_on: None +) -> None: + """Заголовок в НЕканоническом регистре тоже вытесняется, а не остаётся вторым. + + По спеке ASGI имена заголовков в scope всегда lowercase, и uvicorn её соблюдает — + но `_propagate_authenticated_user` на это не полагается. Если бы фильтр сравнивал + сырые байты, в scope осталась бы ВТОРАЯ запись `X-Authenticated-User: admin` рядом + с нашей. Эксплуатируемой дыры это не давало (`Headers.get` лоуэркейсит искомый + ключ, но не хранимый, поэтому смешанный регистр не матчится никогда), но состояние + «две записи с одним именем» ложное по построению — и в чужом ASGI-слое, который + регистр нормализует, оно стало бы подделкой. + """ + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)}) + _install_auth_db(monkeypatch, db) + request = _make_request( + "/api/v1/me", + cookie_token=_VALID_TOKEN, + header_user=_ADMIN_LOGIN, + header_name=b"X-Authenticated-User", + ) + + response, downstream = await _run_guard(request) + + assert response.status_code == 200 + assert downstream.seen_users == [_PILOT_LOGIN] + names = [k for k, _ in request.scope["headers"] if k.lower() == b"x-authenticated-user"] + assert names == [b"x-authenticated-user"], "подделка осталась в scope вторым заголовком" + + +async def test_registry_failure_alert_is_throttled( + monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture +) -> None: + """Лежащий реестр даёт ОДИН ERROR на окно, остальное — WARNING без traceback. + + Guard резолвит сессию на каждом non-public запросе с кукой, а ERROR уезжает + событием в GlitchTip (LoggingIntegration event_level=ERROR). Без троттлинга сбой + реестра выжигал бы квоту за минуты — и настоящие ошибки этого же периода терялись + бы вместе с ней. Факт продолжающегося сбоя при этом остаётся видимым в логах. + """ + _install_auth_db(monkeypatch, None) + + with caplog.at_level(logging.WARNING, logger="app.main"): + for _ in range(3): + response, _ = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)) + assert response.status_code == 401 + + errors = [r for r in caplog.records if r.levelno >= logging.ERROR] + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(errors) == 1, "второй и третий сбой обязаны быть подавлены троттлингом" + assert errors[0].exc_info is not None + assert len(warnings) == 2, "подавленные сбои всё равно обязаны быть видны в логе" + assert all(w.exc_info is None for w in warnings) + + +# --------------------------------------------------------------------------- +# AUTH_MODE=db_only — конечное состояние эпика: легаси-ветка НЕДОСТИЖИМА +# --------------------------------------------------------------------------- + + +@pytest.fixture +def db_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "auth_mode", "db_only") + + +async def test_db_only_ignores_legacy_header_completely( + monkeypatch: pytest.MonkeyPatch, db_only: None, resolve_spy: list[str | None] +) -> None: + """🔴 Ради этого режим и заведён: `X-Authenticated-User` больше не пускает никого. + + Этот режим включается тем же PR, который снимает `basic_auth` + `header_up` из + Caddy, то есть делает заголовок полностью клиентским. Пройди `curl -H + 'X-Authenticated-User: admin'` здесь — это был бы полный обход аутентификации. + """ + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN)) + + assert response.status_code == 401 + assert b"valid session required" in response.body + assert downstream.calls == 0 + assert resolve_spy == [], "куки нет — резолвить нечего" + + +async def test_db_only_rejects_when_registry_is_down( + monkeypatch: pytest.MonkeyPatch, db_only: None, caplog: pytest.LogCaptureFixture +) -> None: + """Сбой реестра в db_only = отказ, а не деградация на заголовок. + + Тот же вход, что в `test_registry_failure_degrades_to_legacy_while_popup_is_up` + (кука + заголовок + лежащий реестр), но исход противоположный. Пара тестов и есть + механическая защита: удалить легаси-фолбэк забудут — этот тест покраснеет, если + db_only начнёт вести себя как dual. + """ + _install_auth_db(monkeypatch, None) + + with caplog.at_level(logging.ERROR, logger="app.main"): + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN) + ) + + assert response.status_code == 401 + assert downstream.calls == 0 + assert [r for r in caplog.records if r.levelno >= logging.ERROR] + + +@pytest.mark.parametrize("access_state", ["disabled", "trial_expired"]) +async def test_db_only_blocked_account_cannot_fall_back_to_header( + monkeypatch: pytest.MonkeyPatch, db_only: None, access_state: str +) -> None: + """Заблокированный в реестре не добирает доступ подделанным заголовком.""" + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN, access_state=access_state)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard( + _make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN) + ) + + assert response.status_code == 401 + assert downstream.calls == 0 + + +async def test_db_only_admits_valid_session(monkeypatch: pytest.MonkeyPatch, db_only: None) -> None: + """Валидная сессия работает и в db_only — режим убирает фолбэк, а не вход.""" + db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)}) + _install_auth_db(monkeypatch, db) + + response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)) + + assert response.status_code == 200 + assert downstream.seen_users == [_PILOT_LOGIN] + + +async def test_db_only_keeps_public_paths_open( + monkeypatch: pytest.MonkeyPatch, db_only: None, no_engine_build: None +) -> None: + """/health и прочие публичные пути остаются публичными — иначе упадёт healthcheck.""" + _install_auth_db(monkeypatch, None) + + response, downstream = await _run_guard(_make_request("/health")) + + assert response.status_code == 200 + assert downstream.calls == 1 diff --git a/backend/tests/test_auth_session_service.py b/backend/tests/test_auth_session_service.py new file mode 100644 index 00000000..51122a89 --- /dev/null +++ b/backend/tests/test_auth_session_service.py @@ -0,0 +1,305 @@ +"""Резолв сессии общего реестра — `app/services/auth_session.py` (эпик «единый вход»). + +Слой ниже guard'а: «что считать валидной сессией» и «когда продлевать». Через guard +эти правила проверяются end-to-end в `tests/test_auth_session_guard.py`; здесь — +поштучно, включая ветки, до которых из guard'а дотянуться дорого (sliding refresh, +сбой продления, исключения БД). + +Дублёр сессии БД (`FakeAuthDb`) намеренно ОДИН на оба файла и живёт в guard-тестах: +разъехавшиеся двойники — типовой способ получить два зелёных теста при одном сломанном +поведении. Прецедент кросс-импорта внутри пакета tests — `tests/integration/*`. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import SecretStr + +from app.core import auth_db +from app.core.auth_db import AuthDatabaseNotConfiguredError +from app.core.config import settings +from app.services import auth_session as svc +from app.services.auth_session import AccessState, SessionUser, get_session_user, to_access_state +from tests.test_auth_session_guard import FakeAuthDb, _install_auth_db, _Row + +_TOKEN = "tok-1" +_USER = "user1" + + +def _row( + *, + expires_in: timedelta = timedelta(days=7), + last_seen_ago: timedelta | None = timedelta(seconds=30), + username: str = _USER, + access_state: str = "active", +) -> _Row: + now = datetime.now(UTC) + return _Row( + expires_at=now + expires_in, + last_seen_at=None if last_seen_ago is None else now - last_seen_ago, + username=username, + access_state=access_state, + ) + + +# --------------------------------------------------------------------------- +# AccessState / to_access_state — fail-closed +# --------------------------------------------------------------------------- + + +def test_only_active_can_sign_in() -> None: + assert AccessState.ACTIVE.can_sign_in is True + assert AccessState.TRIAL_EXPIRED.can_sign_in is False + assert AccessState.DISABLED.can_sign_in is False + + +def test_to_access_state_known_values() -> None: + assert to_access_state("active") is AccessState.ACTIVE + assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED + assert to_access_state("disabled") is AccessState.DISABLED + + +@pytest.mark.parametrize("value", ["frozen", "", None, 42]) +def test_to_access_state_unknown_is_disabled_with_warning( + value: object, caplog: pytest.LogCaptureFixture +) -> None: + """Неизвестное/NULL/чужой тип → disabled + WARNING. + + Миграции БД `auth` применяет деплой «Птицы», то есть новое состояние может + появиться в базе раньше, чем код о нём узнает. Обратный выбор («не disabled = + пускаем») означал бы, что такая миграция молча раздаёт доступ. + """ + with caplog.at_level(logging.WARNING, logger="app.services.auth_session"): + assert to_access_state(value) is AccessState.DISABLED + assert caplog.records + + +# --------------------------------------------------------------------------- +# get_session_user — что считается валидной сессией +# --------------------------------------------------------------------------- + + +def test_valid_session_resolves_to_user() -> None: + db = FakeAuthDb({_TOKEN: _row()}) + + assert get_session_user(db, _TOKEN) == SessionUser( + username=_USER, access_state=AccessState.ACTIVE + ) + assert db.select_tokens == [_TOKEN] + + +def test_empty_token_short_circuits_without_query() -> None: + db = FakeAuthDb({_TOKEN: _row()}) + + assert get_session_user(db, "") is None + assert db.select_tokens == [] + + +def test_unknown_token_returns_none() -> None: + db = FakeAuthDb() + + assert get_session_user(db, "never-issued") is None + assert db.updates == [] + + +def test_expired_session_returns_none_and_is_not_refreshed() -> None: + """Истёкшая сессия не воскресает sliding-refresh'ем — иначе TTL был бы вечным.""" + db = FakeAuthDb( + {_TOKEN: _row(expires_in=timedelta(seconds=-1), last_seen_ago=timedelta(days=1))} + ) + + assert get_session_user(db, _TOKEN) is None + assert db.updates == [] + + +def test_null_expires_at_returns_none() -> None: + """`expires_at IS NULL` (колонку ослабили) → сессии нет, а не TypeError в auth-пути.""" + row = _row() + row.expires_at = None + db = FakeAuthDb({_TOKEN: row}) + + assert get_session_user(db, _TOKEN) is None + + +@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "unheard_of"]) +def test_non_active_user_returns_none_immediately(access_state: str) -> None: + """Блокировка в реестре бьёт сразу, не дожидаясь expires_at (иначе до 30 дней).""" + db = FakeAuthDb({_TOKEN: _row(access_state=access_state)}) + + assert get_session_user(db, _TOKEN) is None + assert db.updates == [] + + +# --------------------------------------------------------------------------- +# Sliding refresh +# --------------------------------------------------------------------------- + + +def test_refresh_skipped_for_recent_session() -> None: + """Свежий last_seen_at → ни одного UPDATE: иначе каждый API-запрос бил бы в БД.""" + db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(seconds=30))}) + + assert get_session_user(db, _TOKEN) is not None + assert db.updates == [] + assert db.commits == 0 + + +@pytest.mark.parametrize( + "last_seen_ago", [timedelta(minutes=5), timedelta(hours=3), None], ids=["at-5m", "3h", "null"] +) +def test_refresh_extends_after_interval(last_seen_ago: timedelta | None) -> None: + """>= 5 минут (и NULL) → один UPDATE на обе колонки + commit. + + TTL берётся из настроек и обязан совпадать с «Мерой»: продлевает сессию тот + продукт, в котором кликнули последним, и срок жизни не должен от этого зависеть. + """ + db = FakeAuthDb({_TOKEN: _row(last_seen_ago=last_seen_ago)}) + + assert get_session_user(db, _TOKEN) is not None + assert db.updates == [{"ttl_hours": settings.session_ttl_hours, "token": _TOKEN}] + assert db.commits == 1 + + +def test_refresh_uses_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "session_ttl_hours", 12) + db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}) + + get_session_user(db, _TOKEN) + + assert db.updates == [{"ttl_hours": 12, "token": _TOKEN}] + + +def test_refresh_failure_does_not_block_valid_session(caplog: pytest.LogCaptureFixture) -> None: + """Продление — best-effort: сбой логируется и откатывается, юзер всё равно валиден. + + Иначе read-only реплика или блокировка строки разлогинивала бы всех, у кого + сессия старше пяти минут. + """ + db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True) + + with caplog.at_level(logging.WARNING, logger="app.services.auth_session"): + user = get_session_user(db, _TOKEN) + + assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE) + assert db.rollbacks == 1 + assert any("sliding refresh failed" in r.getMessage() for r in caplog.records) + # В сообщении не должно быть ни username, ни токена: лог — не место для связки + # «кто именно» + «когда», а разбор идёт по времени. + assert not any(_USER in r.getMessage() or _TOKEN in r.getMessage() for r in caplog.records) + + +def test_select_failure_is_not_swallowed() -> None: + """Сбой SELECT'а летит наружу: решение «что делать со сломанным реестром» — не здесь. + + Проглоти резолвер ошибку — вызывающий получил бы «сессии нет», то есть отказ + выглядел бы как «просто не залогинен», а откат на trusted-header — как норма. + """ + db = FakeAuthDb() + + def _boom(*_a: object, **_k: object) -> None: + raise RuntimeError("auth registry is down") + + db.execute = _boom # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="auth registry is down"): + get_session_user(db, _TOKEN) + + +# --------------------------------------------------------------------------- +# resolve_session_token — точка входа guard'а +# --------------------------------------------------------------------------- + + +def test_resolve_returns_none_without_touching_db_when_flag_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """🔒 Инвариант «выключенный флаг = ни одного коннекта» держится этим модулем. + + Он не полагается на то, что вызывающий сам не позовёт резолв: даже с валидным + токеном соединение не открывается. + """ + monkeypatch.setattr(settings, "auth_mode", "legacy") + _install_auth_db(monkeypatch, None) # открытие сессии → RuntimeError + + assert svc.resolve_session_token(_TOKEN) is None + + +@pytest.mark.parametrize("token", [None, ""]) +def test_resolve_returns_none_for_empty_token( + monkeypatch: pytest.MonkeyPatch, token: str | None +) -> None: + monkeypatch.setattr(settings, "auth_mode", "dual") + _install_auth_db(monkeypatch, None) + + assert svc.resolve_session_token(token) is None + + +def test_resolve_opens_registry_session_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "auth_mode", "dual") + db = FakeAuthDb({_TOKEN: _row()}) + _install_auth_db(monkeypatch, db) + + assert svc.resolve_session_token(_TOKEN) == SessionUser( + username=_USER, access_state=AccessState.ACTIVE + ) + assert db.select_tokens == [_TOKEN] + + +def test_resolve_propagates_not_configured_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Флаг включён, DSN пуст → исключение наружу, а не «сессия не найдена». + + Тихий None здесь означал бы либо массовый отказ доступа под видом «не залогинен», + либо (в guard'е) бессрочную раздачу прав в обход реестра. Настоящий `auth_db` не + подменяется — проверяется именно связка сервис ↔ конфигурация. + """ + monkeypatch.setattr(settings, "auth_mode", "dual") + monkeypatch.setattr(settings, "auth_database_url", "") + monkeypatch.setattr(settings, "auth_db_password", SecretStr("")) + auth_db.reset_auth_db() + try: + with pytest.raises(AuthDatabaseNotConfiguredError): + svc.resolve_session_token(_TOKEN) + finally: + auth_db.reset_auth_db() + + +def test_expiry_is_also_filtered_by_db_clock() -> None: + """Срок годности отсекается ЧАСАМИ БД, а не только часами процесса. + + Асимметрия, которую это закрывает: решение «жива ли сессия» принимал Python + (`datetime.now(UTC)`), а продление писало `expires_at = now() + interval` часами + СЕРВЕРА. Отставание часов приложения давало бы не «сессия проживёт на дельту + дольше», а необратимое воскрешение: строку, которую БД уже считает мёртвой, Python + пропускал бы, тут же срабатывал sliding-refresh и отодвигал expires_at на полный + TTL от серверного now(). Секунда расхождения → +30 дней жизни. + + Форма запроса проверяется дублёром (`_assert_select_shape`), поэтому здесь + достаточно одного прохода: потеряется `AND s.expires_at > now()` — тест покраснеет. + """ + db = FakeAuthDb({_TOKEN: _row()}) + + assert get_session_user(db, _TOKEN) is not None + + +def test_rollback_failure_does_not_break_the_resolve(caplog: pytest.LogCaptureFixture) -> None: + """Сбой самого rollback'а (оборванный коннект) не отменяет валидную сессию. + + Иначе «best-effort продление» переставало быть best-effort: исключение улетало бы + из get_session_user наружу, и валидный юзер получал бы вместо доступа ERROR в + GlitchTip и деградацию на легаси-заголовок (а в db_only — отказ). + """ + db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True) + + def _dead_connection() -> None: + raise RuntimeError("server closed the connection unexpectedly") + + db.rollback = _dead_connection # type: ignore[method-assign] + + with caplog.at_level(logging.WARNING, logger="app.services.auth_session"): + user = get_session_user(db, _TOKEN) + + assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE) + assert any("rollback" in r.getMessage() for r in caplog.records) diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py index 3c2435dc..606e1238 100644 --- a/backend/tests/test_rbac.py +++ b/backend/tests/test_rbac.py @@ -42,11 +42,26 @@ def _reset_auth_cache() -> None: # Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые # импорты (weasyprint, celery worker, ...). Если поведение middleware меняется # в проде — синхронизируй здесь. +# +# NB: копия воспроизводит ЛЕГАСИ-ВЕТКУ принятия решения (trusted-header) и намеренно +# не знает про сессионную куку общего реестра, добавленную эпиком «единый вход»: +# при AUTH_MODE=legacy (дефолт) прод-guard принимает решение ровно так же, и тесты +# ниже проверяют именно тот режим. Режимы dual/db_only (кука → заголовок, приоритет +# куки над подделанным заголовком, 401/403, публичные пути) покрыты в +# tests/test_auth_session_guard.py — там вызывается НАСТОЯЩИЙ app.main.rbac_guard, +# без копии. +# +# «Ровно так же» — про ЛОГИКУ, не про списки: `_PUBLIC_PATHS` ниже держится +# синхронным с прод-версией руками (расхождение уже случалось — в копии не было +# /api/v1/ping), и никакой механики, которая бы это гарантировала, нет. Прод-список +# параметризован в test_auth_session_guard.py, поэтому его расширение хотя бы там +# проверяется автоматически. # --------------------------------------------------------------------------- _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") -_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) +# Синхронно с app.main._PUBLIC_PATHS (там же и /api/v1/ping — он был потерян здесь). +_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) def _build_test_app() -> FastAPI: @@ -110,11 +125,24 @@ def client() -> TestClient: # --------------------------------------------------------------------------- +# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ +# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это +# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод +# именно здесь, отдельной константой, а не магическим `if` в цикле. +_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"} + + def test_get_role_known_users() -> None: + """Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне + `backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой + рассинхрон CI молча пропускает (так и случилось с user2 → expired).""" assert auth_mod.get_role("admin") == "admin" assert auth_mod.get_role("kopylov") == "pilot" for n in range(1, 11): - assert auth_mod.get_role(f"user{n}") == "pilot" + login = f"user{n}" + expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot" + why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин") + assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected} — {why}" def test_get_role_unknown_user_raises() -> None: diff --git a/data/sql/179_mv_quarter_price_cadastral_floor.sql b/data/sql/179_mv_quarter_price_cadastral_floor.sql index 12c689da..e68224f4 100644 --- a/data/sql/179_mv_quarter_price_cadastral_floor.sql +++ b/data/sql/179_mv_quarter_price_cadastral_floor.sql @@ -220,4 +220,8 @@ COMMENT ON MATERIALIZED VIEW mv_quarter_price_index IS 'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. ' 'Issue: #760.'; +-- C3 (#2583): DROP CASCADE выше уносит грант из 99b — без ре-гранта tradein FDW +-- (quarter_price_index, роль tradein_fdw_reader) ловит permission denied. +GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader; + COMMIT; diff --git a/data/sql/188_regrant_quarter_price_index_fdw.sql b/data/sql/188_regrant_quarter_price_index_fdw.sql new file mode 100644 index 00000000..b85dcd25 --- /dev/null +++ b/data/sql/188_regrant_quarter_price_index_fdw.sql @@ -0,0 +1,22 @@ +-- 188_regrant_quarter_price_index_fdw.sql +-- C3 (#2583): tradein-эстиматор потерял квартальный индекс 2026-07-05. +-- +-- Причина: 179_mv_quarter_price_cadastral_floor.sql делает +-- DROP MATERIALIZED VIEW mv_quarter_price_index CASCADE; CREATE ... +-- DROP+CREATE не сохраняет гранты — GRANT из 99b_grant_quarter_price_index_fdw.sql +-- пропал, и tradein-сторона (foreign table quarter_price_index, роль +-- tradein_fdw_reader) с 05.07 получала: +-- permission denied for materialized view mv_quarter_price_index +-- +-- Симптом «не выполняется с 5 июля» — это дата применения 179, а не поломка +-- рефреша: REFRESH MATERIALIZED VIEW (beat: 05:00 МСК 5-го числа) гранты не трогает. +-- +-- Применено вживую на прод 2026-08-04 (FDW-чтение проверено: 1894 строки). +-- Этот файл — идемпотентное закрепление. Парный фикс: GRANT дописан в конец 179, +-- чтобы повторное применение 179 больше не теряло грант. + +BEGIN; + +GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader; + +COMMIT; diff --git a/data/sql/auth/001_identity_schema.sql b/data/sql/auth/001_identity_schema.sql new file mode 100644 index 00000000..f6803113 --- /dev/null +++ b/data/sql/auth/001_identity_schema.sql @@ -0,0 +1,123 @@ +-- auth/001: users + sessions — единое хранилище доступов для «Меры» и «Птицы». +-- +-- WHY (почему отдельная БД и почему таблицы называются нейтрально): +-- Владелец продукта решил (2026-07-31) свести вход в «Меру» (trade-in, /trade-in) и +-- «Птицу» (раздел Site Finder, /site-finder/analysis/[cad]/ptica) к ОДНОЙ нейтральной +-- форме входа, вместо браузерного popup'а Caddy basic_auth. Значит, у хранилища доступов +-- два потребителя, и оно не должно принадлежать ни одному из них: живёт в отдельной БД +-- `auth` на платформенном сервере gendesign-postgres (тот же кластер, отдельная база — +-- новый контейнер не заводим; оба бэкенда сидят в сети gendesign_shared и TCP-достают +-- до gendesign-postgres-1:5432, проверено на проде 2026-07-31). +-- Отсюда имена без префикса продукта: `users`, а не `tradein_users`. Префикс продукта в +-- нейтральном хранилище означал бы, что вторая система — гость в чужой таблице, и через +-- полгода никто бы не помнил, кто владелец схемы. +-- +-- Здесь НЕТ колонки `role` — сознательно. Идентичность («кто это, какой у него пароль, +-- активен ли доступ») общая для двух продуктов; полномочия внутри продукта (admin/manager/ +-- employee в «Мере», админ-роуты в «Птице») — это знание продукта, оно остаётся в +-- продуктовых БД (tradein_users.role) и не переезжает сюда. Иначе `auth` пришлось бы +-- менять каждый раз, когда в одном из продуктов появляется новая роль. +-- +-- WHAT: +-- 1. users — identity. password_hash NULL допустим (см. комментарий к колонке): пароли +-- НИКОГДА не попадают в git, ни plaintext, ни bcrypt-хешем — конвенция репо, прецедент +-- tradein-mvp/backend/data/sql/193_tradein_users_seed.sql. Сид (003) вставляет строки +-- с password_hash = NULL, хеши проставляются на проде отдельно. +-- 2. sessions — токен-based сессии, ON DELETE CASCADE от users (удалили пользователя — +-- его сессии теряют смысл). last_seen_at отдельно от created_at — для idle-timeout, +-- иначе «сессия жива 30 дней» и «человек не заходил 30 дней» неразличимы. +-- 3. ASCII-CHECK на username — обязателен ДО появления прод-данных (см. ниже). +-- +-- IDEMPOTENCY: +-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; CHECK-констрейнты объявлены +-- inline в CREATE TABLE, а не через ALTER — при повторном прогоне CREATE TABLE не +-- выполняется вообще, значит констрейнт физически не может задублироваться (паттерн из +-- 192_tradein_users_auth.sql). +-- +-- Тип id: `bigint GENERATED ALWAYS AS IDENTITY` — стандартный (SQL-standard) эквивалент +-- bigserial: та же bigint-колонка на той же последовательности, но sequence принадлежит +-- таблице жёстко и не переживает DROP COLUMN сиротой, а прямой INSERT в id запрещён +-- (случайная вставка «своего» id, ломающая счётчик, невозможна). Ровно так объявлен +-- tradein_users.id в 192 — держим один тип на обе таблицы, чтобы будущий код, читающий +-- обе, не спотыкался о разницу. +-- +-- Dependencies: нет (пустая БД `auth`, создаётся bootstrap-шагом деплоя, +-- см. ops/db-bootstrap/create_auth_db.sql). +-- Deploy order: Foundation. Роль приложения + гранты — 002, сид — 003. Python-код логина, +-- логин-страница и снятие Caddy basic_auth — отдельные PR'ы ПОСЛЕ этого +-- (SQL-схема первой, см. .claude/rules/sql.md «Migration order»). + +BEGIN; + +CREATE TABLE IF NOT EXISTS users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username text NOT NULL UNIQUE, + password_hash text NULL, + display_name text NULL, + org_name text NULL, + email text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$') +); + +COMMENT ON TABLE users IS + 'Единое хранилище доступов для «Меры» (trade-in) и «Птицы» (Site Finder) — только ' + 'идентичность. Полномочия внутри продукта (роли) остаются в продуктовых БД: иначе эту ' + 'таблицу пришлось бы менять при каждом изменении ролевой модели любого из продуктов.'; + +COMMENT ON COLUMN users.password_hash IS + 'NULL = пароль ещё не проставлен, вход по паролю для этой строки невозможен. Хеши ' + 'НИКОГДА не хранятся в git (ни в сидах, ни в фикстурах) — их проставляют на проде ' + 'отдельно от миграции; иначе один утёкший коммит открывает вход всем аккаунтам сразу.'; + +COMMENT ON COLUMN users.is_active IS + 'false = доступ закрыт владельцем продукта. Отдельная колонка, а не удаление строки: ' + 'удаление каскадом снесло бы сессии и историю, а закрытие доступа обратимо и его надо ' + 'уметь отличать от «такого пользователя никогда не было».'; + +COMMENT ON COLUMN users.org_name IS + 'Организация пользователя. NULL, пока реальные данные не подтверждены владельцем ' + 'продукта — выдуманное название хуже пустого, оно выглядит достоверным.'; + +COMMENT ON CONSTRAINT users_username_ascii_ck ON users IS + 'Fail-closed запрет не-ASCII логинов (перенесено из tradein м.193, deep-review #2561): ' + 'downstream-код кодирует username сессии через encode("latin-1","replace"), поэтому два ' + 'кириллических логина ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку из «?» — ' + 'разные люди получают общую идентичность, общую квоту и взаимный IDOR (один видит данные ' + 'другого). Констрейнт на уровне схемы, а не проверка в UI/API: проверку в коде однажды ' + 'забудут добавить в новый путь создания пользователя, схему обойти нельзя.'; + +CREATE TABLE IF NOT EXISTS sessions ( + token text PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL DEFAULT now(), + ip_address inet NULL, + user_agent text NULL +); + +COMMENT ON TABLE sessions IS + 'Активные сессии единой формы входа (общие для «Меры» и «Птицы»). ON DELETE CASCADE от ' + 'users: оставшаяся сессия удалённого пользователя — это действующий доступ без владельца.'; + +COMMENT ON COLUMN sessions.last_seen_at IS + 'Обновляется на каждом запросе — нужен для idle-timeout: без него «сессия не истекла» и ' + '«человек ещё работает» неразличимы, и забытая открытая вкладка живёт до expires_at.'; + +COMMENT ON COLUMN sessions.ip_address IS + 'IP на момент выдачи токена — для разбора инцидентов («откуда зашли под этим логином»), ' + 'не для авторизации: привязка к IP ломает мобильных пользователей при смене сети.'; + +-- Индексы — как в tradein м.192: уборка протухших сессий по expires_at и выборка/отзыв +-- всех сессий одного пользователя по user_id (FK сам по себе индекс не создаёт, а без него +-- ON DELETE CASCADE на users делает seq scan по всей таблице сессий). +CREATE INDEX IF NOT EXISTS sessions_expires_at_idx + ON sessions (expires_at); + +CREATE INDEX IF NOT EXISTS sessions_user_id_idx + ON sessions (user_id); + +COMMIT; diff --git a/data/sql/auth/002_auth_app_role.sql b/data/sql/auth/002_auth_app_role.sql new file mode 100644 index 00000000..48dfe277 --- /dev/null +++ b/data/sql/auth/002_auth_app_role.sql @@ -0,0 +1,82 @@ +-- auth/002: роль приложения auth_app + гранты (least privilege). +-- +-- WHY: +-- Миграции этой БД прогоняются суперюзером кластера ($POSTGRES_USER), он же владелец +-- таблиц. Бэкенды «Меры» и «Птицы» ходить под суперюзером не должны: скомпрометированный +-- бэкенд не обязан уметь DROP TABLE users. Поэтому отдельная login-роль с точечными +-- грантами. БД `auth` НЕ принадлежит auth_app (владелец — суперюзер): владелец таблицы +-- имеет на неё все права независимо от GRANT'ов, и разграничение ниже стало бы фикцией. +-- +-- Пароль роли здесь НЕ задаётся — роль создаётся passwordless, пароль ставится отдельным +-- bootstrap-шагом деплоя из env (AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime, +-- см. ops/db-bootstrap/set_auth_app_password.sql). Ровно тот же паттерн, что у +-- gendesign_reader (tradein м.101 + set_gendesign_reader_password.sql) и tradein_fdw_reader +-- (data/sql/100_tradein_fdw_role.sql). Пароль в git не попадает ни при каких условиях. +-- +-- Периметр прав (обосновано по-операционно): +-- sessions — SELECT/INSERT/UPDATE/DELETE. Полный набор: выдать токен (INSERT), проверить +-- на каждом запросе (SELECT), обновить last_seen_at (UPDATE), разлогинить и вычистить +-- протухшие (DELETE). +-- users — SELECT (найти по username, прочитать hash и is_active) + UPDATE (смена пароля +-- самим пользователем и проставление хеша админом). +-- users — INSERT/DELETE НЕ выдаются, сознательно: +-- * INSERT — создание аккаунтов в PR-1 не существует ни как код, ни как UI. Выдать грант +-- «на будущее» = держать открытой операцию, которой никто не пользуется и которую никто +-- не тестирует. Когда появится админский путь создания пользователей, грант добавляется +-- новой миграцией в одну строку (плюс GRANT USAGE на sequence, идентичность требует +-- nextval). Обратная ошибка дороже: снять грант, на который уже опирается прод-код, +-- нельзя без синхронного релиза. +-- * DELETE — не выдаётся и дальше: закрытие доступа делается через is_active = false +-- (см. комментарий к колонке в 001). Физическое удаление каскадом сносит сессии и +-- обрывает связь с историей действий пользователя в продуктовых БД, где user_id/username +-- остаются висеть; это операция уровня «руками через psql с осознанием последствий», +-- а не то, что должен уметь HTTP-хендлер. +-- +-- IDEMPOTENCY: +-- CREATE ROLE через DO-блок с проверкой pg_roles (нет ADD ROLE IF NOT EXISTS), GRANT/REVOKE +-- идемпотентны по определению. Повторный прогон — no-op. Роли в PostgreSQL общие на кластер, +-- поэтому DO-блок отработает корректно, даже если роль уже создана из другой БД. +-- +-- Dependencies: 001_identity_schema.sql (гранты ссылаются на users/sessions). + +BEGIN; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN + CREATE ROLE auth_app LOGIN; + END IF; +END$$; + +COMMENT ON ROLE auth_app IS + 'Прикладная роль единой формы входа («Мера» + «Птица»). Пароль ставится ' + '.forgejo/workflows/deploy.yml из env AUTH_DB_PASSWORD (backend/.env.runtime) через ' + 'ops/db-bootstrap/set_auth_app_password.sql. Пароль никогда не хранится в SQL-миграциях.'; + +-- Никто, кроме владельца БД и явно поименованных ролей, не должен даже подключаться: +-- по умолчанию PostgreSQL даёт CONNECT роли PUBLIC, то есть любая login-роль кластера +-- (glitchtip, tradein_fdw_reader, gendesign_reader) может открыть сессию в `auth`. +-- Хранилище паролей — не то место, где стоит полагаться на «а таблицы им всё равно не видны». +-- +-- ЭТА СТРОКА ПРОДУБЛИРОВАНА в ops/db-bootstrap/create_auth_db.sql — намеренно, инвариант +-- держится в двух местах. Здесь — ради самодостаточности миграции: применённая на пустую БД +-- (scratch/staging, ручной psql -f) она обязана давать полный периметр прав, не полагаясь на +-- то, что кто-то отдельно прогнал bootstrap. В bootstrap — ради переприменяемости: миграция +-- выполняется РОВНО ОДИН РАЗ (трекинг в _schema_migrations), а БД может быть пересоздана из +-- дампа в обход миграций, и тогда дефолтный PUBLIC-CONNECT вернулся бы молча. Не «сокращай» +-- дубль — ни одна из копий не покрывает сценарий другой. +REVOKE ALL ON DATABASE auth FROM PUBLIC; + +-- Defense-in-depth: явный REVOKE-периметр перед точечными грантами — любые унаследованные +-- или PUBLIC-гранты на существующих объектах обнуляются (паттерн из 100_tradein_fdw_role.sql). +REVOKE ALL ON ALL TABLES IN SCHEMA public FROM auth_app; +REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM auth_app; +REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM auth_app; + +GRANT CONNECT ON DATABASE auth TO auth_app; +GRANT USAGE ON SCHEMA public TO auth_app; + +GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO auth_app; +GRANT SELECT, UPDATE ON users TO auth_app; + +COMMIT; diff --git a/data/sql/auth/003_users_seed.sql b/data/sql/auth/003_users_seed.sql new file mode 100644 index 00000000..6559d88d --- /dev/null +++ b/data/sql/auth/003_users_seed.sql @@ -0,0 +1,111 @@ +-- auth/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31). +-- +-- WHY: +-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты +-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml) +-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ. +-- +-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни +-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/ +-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на +-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию, +-- и он переживает любую ротацию пароля в истории коммитов. +-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ +-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий +-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы. +-- +-- Состав (утверждён владельцем продукта): +-- admin — владелец +-- kopylov — отдельный клиент, display_name «Копылов» +-- praktika — ГК «Практика» +-- user1, user3..user10 — свободные слоты, is_active = true +-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30); +-- в roles.yaml он role=expired — расхождение семантики, +-- см. ⚠️ у строки user2 в VALUES ниже +-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде: +-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать +-- нельзя: выдуманное ФИО в UI неотличимо от настоящего. +-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов. +-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам): +-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet +-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят. +-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping, +-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними +-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest +-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py), +-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired. +-- +-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564): +-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name, +-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или +-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в +-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при +-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен +-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id +-- тихо обнулялся повторным прогоном сида. +-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но +-- значение из БД никогда не перетирается сидом. +-- +-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит +-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью +-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в +-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно +-- «замораживается» на текущем значении в БД. +-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role +-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.) +-- +-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически +-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает. +-- +-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт +-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый +-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно; +-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили». +-- +-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже +-- ASCII, констрейнту не противоречат). + +BEGIN; + +INSERT INTO users (username, password_hash, display_name, org_name, email, is_active) +VALUES + ('admin', NULL, NULL, NULL, NULL, true), + ('kopylov', NULL, 'Копылов', NULL, NULL, true), + ('praktika', NULL, NULL, NULL, NULL, true), + ('user1', NULL, NULL, NULL, NULL, true), + -- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30. + -- + -- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь: + -- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false. + -- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в + -- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран + -- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**"; + -- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа, + -- неотличимый для пользователя от «неверный пароль». + -- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через + -- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент + -- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет + -- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное + -- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает + -- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем. + ('user2', NULL, NULL, NULL, NULL, false), + ('user3', NULL, NULL, NULL, NULL, true), + ('user4', NULL, NULL, NULL, NULL, true), + ('user5', NULL, NULL, NULL, NULL, true), + ('user6', NULL, NULL, NULL, NULL, true), + ('user7', NULL, NULL, NULL, NULL, true), + ('user8', NULL, NULL, NULL, NULL, true), + ('user9', NULL, NULL, NULL, NULL, true), + ('user10', NULL, NULL, NULL, NULL, true) +ON CONFLICT (username) DO UPDATE SET + -- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает + -- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон + -- отключал бы вход всем, кому пароль уже выдали). + password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash), + display_name = COALESCE(users.display_name, EXCLUDED.display_name), + org_name = COALESCE(users.org_name, EXCLUDED.org_name), + email = COALESCE(users.email, EXCLUDED.email), + -- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY). + updated_at = now(); + +COMMIT; diff --git a/data/sql/auth/004_users_roles_and_access_state.sql b/data/sql/auth/004_users_roles_and_access_state.sql new file mode 100644 index 00000000..5a83e356 --- /dev/null +++ b/data/sql/auth/004_users_roles_and_access_state.sql @@ -0,0 +1,408 @@ +-- auth/004: продуктовые роли + org-иерархия + трёхзначный access_state вместо булева is_active. +-- +-- ⚠️ ЭТА МИГРАЦИЯ СОЗНАТЕЛЬНО ОТМЕНЯЕТ РЕШЕНИЯ, ЗАПИСАННЫЕ В 001 И 002. +-- Это не рассинхрон и не ошибка автора: решение владельца продукта от 2026-07-31 принято +-- ПОСЛЕ того, как 001-003 были написаны и применены на проде. Применённую миграцию править +-- нельзя (повторно она не выполнится — трекинг в _schema_migrations), поэтому актуальная +-- правда живёт здесь, а в 001/002 остаются исторические формулировки: +-- * 001:15-19 «Здесь НЕТ колонки role — сознательно» → ОТМЕНЕНО, см. WHY-1; +-- * 002:22-33 «users — INSERT/DELETE НЕ выдаются, сознательно» → ОТМЕНЕНО ЧАСТИЧНО: INSERT +-- выдаётся (без него переезд не состоится), DELETE — по-прежнему нет, см. Часть 4; +-- * 002:26-27 «идентичность требует nextval» (грант USAGE на sequence) → ФАКТИЧЕСКИ +-- НЕВЕРНО, гранта не требуется; проверено, разбор в Части 4; +-- * 003:78-90 «открытая развилка про trial-экран, решается в PR-2/3» → ЗАКРЫТА, см. WHY-2. +-- Ориентир для читателя: актуальное состояние колонок описано COMMENT'ами в БД, они +-- переписаны здесь. Заголовок 001 — археология, а не спецификация. +-- +-- WHY-1 — продуктовые роли переезжают в `auth` (отмена решения 001): +-- 001 строилась на схеме «идентичность общая, полномочия у продукта»: auth.users знает, КТО +-- человек, tradein_users знает, ЧТО ему можно. Владелец выбрал другой сценарий — ПОЛНЫЙ +-- переезд: tradein_users (БД tradein) в итоге удаляется, auth.users остаётся единственным +-- реестром людей. Как только реестр один, роль перестаёт быть «знанием продукта»: без неё в +-- auth.users нельзя ни завести сотрудника, ни собрать раздел «Команда», ни ответить на вопрос +-- «чьи заявки видит этот менеджер» — а спросить больше не у кого, второй таблицы не будет. +-- Промежуточный вариант (человек в auth.users, его роль в tradein_users) — это два реестра, +-- которые кто-то обязан держать синхронными руками; их расхождение выглядит как «пользователь +-- есть, но он никто» и чинится только вручную по факту жалобы. +-- Цена решения ровно та, которую 001 и называла: новая роль в любом из продуктов = миграция +-- этой БД. Принято сознательно — это дешевле, чем двойной реестр людей. +-- +-- WHY-2 — три состояния доступа вместо булева is_active (закрытие развилки из 003): +-- Булев флаг схлопывает два РАЗНЫХ события в одно значение: «пробный период закончился» и +-- «доступ закрыт владельцем». Для пользователя разница видимая и она уже реализована в +-- сегодняшнем стеке: expired-аккаунт доходит до фронта и видит осмысленный экран «пробный +-- доступ закончился» (auth/roles.yaml → expired: paths: [] + deny "/**"; frontend +-- NoAccessScreen variant="trial"), а закрытый — просто не входит. Переключившись на единую +-- форму входа с булевым is_active, мы бы потеряли trial-экран МОЛЧА: состояние перестало бы +-- существовать, и ни один тест бы не упал. Ровно это и было записано как открытая развилка в +-- 003:78-90. Решение: состояний три. +-- active — доступ есть, обычный вход. +-- trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: логин отвечает 403 с отдельным +-- кодом и текстом «пробный доступ закончился», сессия НЕ выдаётся. +-- disabled — жёсткая блокировка: generic 401, для пользователя неотличимо от «неверный +-- пароль». +-- Неверный пароль в ЛЮБОМ состоянии → generic 401. Иначе отдельный 403 превращается в оракул +-- существования логина: перебором можно перечислить аккаунты, не зная ни одного пароля. +-- Осмысленный ответ полагается только тому, кто пароль уже доказал. +-- text + CHECK, а не enum-тип: добавить четвёртое состояние — это ALTER одного констрейнта в +-- обычной миграции, тогда как ALTER TYPE ... ADD VALUE нельзя использовать в той же +-- транзакции, где значение добавлено (PG16), и enum тянет за собой отдельный тип в дампах. +-- Enum-типов в репозитории нет вовсе — не заводим первый ради трёх значений. +-- +-- WHAT: +-- 1. role — text NOT NULL + CHECK ('admin','manager','employee'). Тип, набор значений +-- и отсутствие DEFAULT — зеркало tradein_users.role (м.192:42). +-- 2. manager_id — self-FK ON DELETE SET NULL + иерархический CHECK + запрет self-manager + +-- partial index. Зеркало м.192:43/50-52/84-86, чтобы код «Меры» переехал на +-- auth.users без правок. +-- 3. access_state — text NOT NULL DEFAULT 'active' + CHECK на три значения; backfill из +-- is_active, точечный перевод user2 («Брусника») в trial_expired, затем +-- DROP COLUMN is_active. +-- 4. Гранты auth_app — INSERT на users (DELETE НЕ выдаётся) + сужение табличного UPDATE (002:80) до +-- column-level: новые колонки role/access_state не должны попасть под него +-- молча. +-- +-- IDEMPOTENCY: +-- ADD COLUMN IF NOT EXISTS / DROP COLUMN IF EXISTS / CREATE INDEX IF NOT EXISTS; констрейнты — +-- через DO-блок с проверкой pg_constraint (в PostgreSQL нет ADD CONSTRAINT IF NOT EXISTS для +-- CHECK/FK, паттерн из м.193:80-90); GRANT идемпотентен по определению; UPDATE-backfill'ы +-- отфильтрованы так, что второй прогон не находит строк (детали у каждого блока). +-- Проверка pg_constraint здесь фильтрует ДОПОЛНИТЕЛЬНО по conrelid (в отличие от м.193, где +-- только conname): имена констрейнтов уникальны в пределах таблицы, а не БД — одноимённый +-- констрейнт на соседней таблице заставил бы миграцию молча пропустить создание своего. +-- +-- ⚠️ ПОСЛЕ 004 ФАЙЛЫ 001 И 003 БОЛЬШЕ НЕ ПЕРЕИГРЫВАЮТСЯ ПООТДЕЛЬНОСТИ. +-- Обе ссылаются на колонку is_active, которой после этой миграции нет, и обе падают на уже +-- мигрированной БД с «column is_active does not exist»: +-- * 001 — на `COMMENT ON COLUMN users.is_active` (001:75). CREATE TABLE IF NOT EXISTS +-- пропускается, а COMMENT выполняется всегда — то есть ручной `psql -f 001` падает +-- РАНЬШЕ 003, вопреки интуиции «ломается только сид». +-- * 003 — на INSERT со списком колонок, включающим is_active (а если бы и не упал — +-- role NOT NULL без DEFAULT не даст вставить строку). +-- Это следствие требования «применённые миграции не правим», а не регресс. Поддерживаемый +-- сценарий восстановления — прогон каталога ЦЕЛИКОМ по возрастанию номеров (001→002→003→004) +-- на пустой БД; он рабочий, порядок гарантирован сортировкой имён в deploy.yml. Нужно добить +-- сид на живой БД — пиши новый файл 00N, не переигрывай 003. +-- +-- Dependencies: 001_identity_schema.sql (users), 002_auth_app_role.sql (роль auth_app — гранты +-- Части 4 её предполагают), 003_users_seed.sql (13 строк, которым backfill проставляет role). +-- Deploy order: применяется на прод авто-циклом deploy.yml по data/sql/auth/*.sql. Python-кода в +-- этом PR нет и поведение прода не меняется — в БД `auth` пока никто не ходит; код логина, +-- чтение role/access_state и удаление tradein_users — отдельные PR'ы ПОСЛЕ (см. +-- .claude/rules/sql.md «Migration order»: схема первой). + +BEGIN; + +-- --------------------------------------------------------------------------------------------- +-- Часть 1: role +-- --------------------------------------------------------------------------------------------- +-- DEFAULT сознательно НЕТ (как в м.192): роль — осознанное решение того, кто заводит человека. +-- С дефолтом INSERT, забывший указать роль, тихо создал бы работающий аккаунт с полномочиями +-- «по умолчанию»; без дефолта он падает на NOT NULL — это и есть нужное поведение. +-- Колонка добавляется NULLable, заполняется backfill'ом ниже и только потом получает NOT NULL: +-- прямой ADD COLUMN ... NOT NULL без DEFAULT упал бы на 13 уже существующих строках сида. +ALTER TABLE users ADD COLUMN IF NOT EXISTS role text; + +-- Backfill. Источник истины — м.193:101-113 (org-карта владельца продукта от 2026-07-30), +-- сверено построчно по файлу, не по памяти. Роли не являются секретом: они уже лежат в git +-- (м.193 и auth/roles.yaml) — запрет на git касается паролей и хешей, не полномочий. +-- `role IS NULL` в каждом WHERE даёт сразу две вещи: идемпотентность (второй прогон не находит +-- строк) и защиту от отката ручных решений — повышение сотрудника до manager, сделанное после +-- первого прогона, повторным применением файла не вернётся к seed-значению. +UPDATE users SET role = 'admin' WHERE role IS NULL AND username = 'admin'; +UPDATE users SET role = 'manager' WHERE role IS NULL AND username IN ('kopylov', 'praktika'); +-- Catch-all — ПОСЛЕДНИМ и именно employee: любая строка, попавшая в auth.users мимо сида +-- (ручная вставка, восстановление из дампа, будущий аккаунт), получает НАИМЕНЕЕ +-- привилегированную роль. Fail-safe: ошибка в этом месте не должна раздавать admin. +UPDATE users SET role = 'employee' WHERE role IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_role_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_role_ck CHECK (role IN ('admin', 'manager', 'employee')); + END IF; +END $$; + +-- SET NOT NULL идемпотентен (на уже NOT NULL колонке — no-op) и стоит ПОСЛЕ backfill: на строке +-- с NULL он упал бы, а catch-all выше гарантирует, что таких строк не осталось. +ALTER TABLE users ALTER COLUMN role SET NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- Часть 2: manager_id (org-иерархия) +-- --------------------------------------------------------------------------------------------- +-- FK и CHECK объявлены ОТДЕЛЬНЫМИ шагами, а не inline в ADD COLUMN (как в м.192, где это было +-- частью CREATE TABLE IF NOT EXISTS — «всё или ничего»). Причина: `ADD COLUMN IF NOT EXISTS ... +-- REFERENCES ...` пропускает ВЕСЬ оператор, если колонка уже есть, — на БД, где manager_id +-- когда-то завели руками без FK, миграция отчиталась бы об успехе и оставила связь без +-- ссылочной целостности. Раздельные идемпотентные шаги такого состояния не допускают. +-- Имя FK задано явно тем же, которое сгенерировал бы PostgreSQL для inline-формы, — чтобы схема +-- на проде и схема из чистой сборки не различались именами констрейнтов. +ALTER TABLE users ADD COLUMN IF NOT EXISTS manager_id bigint; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_manager_id_fkey' AND conrelid = 'users'::regclass + ) THEN + -- ON DELETE SET NULL (зеркало м.192:43): удаление менеджера не должно каскадом сносить + -- его сотрудников — они остаются в реестре без привязки, и это чинится назначением + -- нового менеджера, а не восстановлением строк из бэкапа. + ALTER TABLE users + ADD CONSTRAINT users_manager_id_fkey + FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_role_manager_hierarchy_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_role_manager_hierarchy_ck CHECK ( + role NOT IN ('admin', 'manager') OR manager_id IS NULL + ); + END IF; +END $$; + +-- Запрет self-manager. users_role_manager_hierarchy_ck выше держит только admin/manager; для +-- employee self-FK допускает ссылку строки на саму себя, и `UPDATE users SET manager_id = id` +-- прошёл бы. Через сегодняшний API это недостижимо (team.py:398-406 требует role='manager' у +-- цели, PATCH manager_id вообще не меняет), но 004 делает auth.users ЕДИНСТВЕННЫМ реестром — в +-- него начнёт писать и «Птица», у которой этой валидации нет, а любой будущий WITH RECURSIVE по +-- manager_id на такой строке зациклится. Строчный CHECK ловит самый вероятный случай (опечатка +-- или копипаста собственного id) и стоит ноль. +-- Чего этот констрейнт НЕ ловит: взаимную пару employee↔employee (A.manager_id=B, +-- B.manager_id=A) и ссылку на строку с role<>'manager' — оба требуют чтения ДРУГОЙ строки, +-- строчным CHECK'ом это не выражается (нужен триггер или FK на несуществующий уникальный ключ +-- (id, role)). Инвариант зафиксирован COMMENT'ом к колонке — он живёт в приложении. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_manager_not_self_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_manager_not_self_ck CHECK ( + manager_id IS NULL OR manager_id <> id + ); + END IF; +END $$; + +-- Partial index (зеркало м.192:84-86): у admin/manager и у свободных слотов manager_id = NULL, +-- и эти строки никогда не участвуют в выборке «сотрудники этого менеджера». Индексировать NULL'ы +-- значит платить за большую часть таблицы, которая по этому пути не читается. +CREATE INDEX IF NOT EXISTS users_manager_id_idx + ON users (manager_id) + WHERE manager_id IS NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- Часть 3: access_state вместо is_active +-- --------------------------------------------------------------------------------------------- +-- DEFAULT 'active' здесь, в отличие от role, уместен: «доступ есть» — это состояние, в котором +-- заводят любого нового сотрудника, и молчаливый дефолт не расширяет ничьих полномочий. +ALTER TABLE users ADD COLUMN IF NOT EXISTS access_state text NOT NULL DEFAULT 'active'; + +-- CHECK ставится СРАЗУ после колонки, до backfill'а: тогда он проверяет и сам backfill — +-- опечатка в значении ниже уронит миграцию, а не просочится в данные. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'users_access_state_ck' AND conrelid = 'users'::regclass + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_access_state_ck CHECK ( + access_state IN ('active', 'trial_expired', 'disabled') + ); + END IF; +END $$; + +-- Backfill из is_active — под проверкой существования колонки, потому что в конце этого же +-- блока она удаляется: повторный прогон файла обязан пройти без ошибок, а прямое обращение к +-- несуществующей колонке — ошибка парсинга, не «0 строк». +-- EXECUTE (динамический SQL), а не обычные UPDATE внутри IF: обычные операторы уцелели бы лишь +-- благодаря ленивой подготовке операторов в PL/pgSQL (невыполненная ветка не разбирается). Это +-- рабочая, но недокументированная в самом файле деталь реализации; EXECUTE делает независимость +-- от отсутствующей колонки явной для читателя. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'users'::regclass + AND attname = 'is_active' + AND NOT attisdropped + ) THEN + -- Механическое отображение старой семантики: булев «доступ закрыт» = жёсткая блокировка. + -- `access_state = 'active'` в WHERE — не мёртвое условие: оно фиксирует, что переписывается + -- только значение, доставшееся из DEFAULT, и никогда — уже осмысленно проставленное. + EXECUTE $q$ + UPDATE users + SET access_state = 'disabled' + WHERE is_active = false + AND access_state = 'active' + $q$; + + -- Точечно: user2 («Брусника», доступ закрыт владельцем 2026-07-30) — не disabled, а + -- trial_expired. Основание: в auth/roles.yaml у него role=expired, то есть исторически он + -- видит trial-экран, а не отказ входа; решение владельца от 2026-07-31 эту семантику + -- сохраняет. + -- Условие `access_state = 'disabled'` — это защита от затирания ручного решения: + -- переводится РОВНО то значение, которое механическая ветка выше только что и вывела. + -- Если к моменту повторного прогона владелец уже открыл «Бруснике» доступ (active) или + -- перевёл её в другое состояние, WHERE не сматчится и решение человека переживёт миграцию. + -- Безусловный UPDATE по username возвращал бы аккаунт в trial_expired после каждого + -- прогона, и разбор «почему у клиента снова экран пробного периода» стоил бы часов при + -- нулевой пользе. Хардкод одного username оправдан: это разовая фиксация конкретного + -- исторического факта, а не правило — общего признака «пробный доступ» в схеме до сих пор + -- не было, выводить его задним числом не из чего. + EXECUTE $q$ + UPDATE users + SET access_state = 'trial_expired' + WHERE username = 'user2' + AND access_state = 'disabled' + $q$; + END IF; +END $$; + +-- Снятие is_active. Деструктивный шаг — но именно он и есть смысл решения: оставить обе колонки +-- значило бы два источника правды о доступе, расходящихся при первой же правке через UI. +-- Безопасно: на момент этого PR БД `auth` не читается ни одним работающим кодом (Caddy basic_auth +-- + tradein_users по-прежнему обслуживают прод), а данные колонки полностью перенесены выше. +-- DROP обязан жить именно здесь, а не в 003: 003 применён на проде и правке не подлежит. +ALTER TABLE users DROP COLUMN IF EXISTS is_active; + +-- --------------------------------------------------------------------------------------------- +-- Часть 4: гранты auth_app под режим единственного реестра (отмена решения 002:22-33) +-- + сужение унаследованного табличного UPDATE до column-level +-- --------------------------------------------------------------------------------------------- +-- 002 намеренно не выдавала INSERT/DELETE на users, и её аргумент был верным для своего момента: +-- в PR-1 не существовало ни кода, ни UI создания аккаунтов, а грант «на будущее» — это открытая +-- операция, которой никто не пользуется и которую никто не тестирует. Аргумент перестаёт +-- применяться ровно сейчас: после полного переезда auth.users — единственный реестр людей, а +-- раздел «Команда» «Меры» (tradein-mvp/backend/app/api/v1/team.py: POST /employees заводит +-- сотрудника, PATCH правит) — единственный интерфейс, которым сотрудника заводят и убирают. +-- Без INSERT переезд физически не состоится: сегодняшний INSERT идёт в tradein_users, а её не +-- станет. +-- DELETE здесь НЕ выдаётся, хотя первая редакция этой миграции его содержала. Причина отказа: +-- DELETE-эндпоинта в team.py нет (только POST /employees и PATCH — проверено), то есть потребителя +-- у права нет ни одного, а 002:22-33 отклоняла ровно такие гранты-на-будущее. Симметричный +-- контраргумент («снять неиспользуемое право дешевле, чем добавлять его в момент релиза») здесь не +-- перевешивает: DELETE по users каскадит на sessions (001:94), то есть цена ошибки в коде выше +-- обычной, а добавить строку GRANT в миграцию того PR, где появится DELETE-хендлер, стоит ровно +-- столько же. Право выдаётся вместе с кодом, который им пользуется, — не раньше. +-- DELETE ≠ закрытие доступа. Закрытие — это access_state ('disabled' / 'trial_expired'): +-- обратимо, сохраняет строку и историю. Именно оно, а не удаление строки, закрывает сегодняшний +-- сценарий «Команды»; удаление понадобилось бы только чтобы убрать ошибочно заведённый слот. +GRANT INSERT ON users TO auth_app; + +-- Гранта на последовательность users_id_seq здесь НЕТ — и это не забывчивость. +-- 002:26-27 записала как факт, что «идентичность требует nextval», то есть INSERT из auth_app +-- якобы упадёт с «permission denied for sequence» без USAGE на последовательности. Для +-- `GENERATED ALWAYS AS IDENTITY` (001:53) это неверно: PostgreSQL подставляет не вызов +-- nextval('...'), а узел NextValueExpr, который дёргает nextval_internal(seqid, +-- check_permissions := false) — ACL последовательности не проверяется вовсе. Это документированное +-- отличие identity от serial, и оно проверено живьём на postgres:16, а не выведено из +-- документации: после `REVOKE ALL ON SEQUENCE users_id_seq FROM app` INSERT в identity-таблицу +-- прошёл и вернул id, тогда как в контрольной таблице с bigserial тот же INSERT в тех же +-- условиях упал ровно с «permission denied for sequence». +-- Отсюда два следствия. Первое: грант не нужен — он выдал бы auth_app право звать +-- nextval('users_id_seq') напрямую (жечь идентификаторы) и читать last_value (число заведённых +-- аккаунтов), при том что ни один путь кода этого не делает; это прямо противоречило бы +-- REVOKE ALL ON ALL SEQUENCES из 002:73. Второе: «живая проверка» вида «auth_app сделал INSERT, +-- значит грант рабочий» ничего не доказывает — тот же INSERT проходит и после REVOKE, поэтому +-- проверять надо обратное (REVOKE, затем INSERT). +-- Если users.id когда-нибудь переведут на обычный DEFAULT nextval(...) — грант станет +-- обязательным, и его придётся добавить той же миграцией, что меняет колонку. + +-- Сужение UPDATE до column-level. 002:80 выдала ТАБЛИЧНЫЙ `GRANT SELECT, UPDATE ON users`, +-- обосновав его узко («смена пароля самим пользователем и проставление хеша админом»), — но +-- табличный UPDATE автоматически распространяется на любые колонки, добавленные позже. Не сузь +-- мы его здесь, auth_app молча получил бы право писать role и access_state, и периметр 002 +-- расширился бы ровно тем, что 004 добавила, без единой строки GRANT. +-- Почему это важно именно для этих двух колонок: любая SQL-инъекция или логическая ошибка в +-- UPDATE-эндпоинте (сегодня такой ровно один — team.py PATCH /employees, COALESCE-список полей +-- по WHERE id = :id) из «испортил профиль» превращалась бы в `SET role='admin' WHERE id=<свой>` +-- или `SET access_state='active' WHERE username='user2'` — тихое повышение до админа и тихое +-- снятие блокировки, без смены пароля, то есть без внешнего признака компрометации. Это ровно +-- тот класс, ради которого 002 и заводила отдельную роль (002:5-6). +-- role в список НЕ включена сознательно: сегодня её не пишет никто (team.py POST вставляет +-- литерал 'employee', PATCH в SET-списке role/manager_id не имеет вовсе). Появится админский +-- путь смены роли — добавится одной строкой новой миграции; это дешевле, чем держать открытым +-- право на эскалацию привилегий «на всякий случай». +-- manager_id по той же причине не включён: назначение сотрудника менеджеру сегодня делается +-- только при создании (INSERT), а не UPDATE'ом. +-- access_state включён — блокировка/разблокировка через «Команду» (сегодняшний +-- `is_active = COALESCE(...)` в PATCH) переезжает именно в эту колонку. +-- REVOKE перед GRANT обязателен и идемпотентен: REVOKE табличной привилегии снимает и +-- колоночные, поэтому повторный прогон файла даёт то же состояние (внутри одной транзакции, +-- то есть без окна «прав нет» для работающего приложения). +REVOKE UPDATE ON users FROM auth_app; +GRANT UPDATE (password_hash, display_name, org_name, email, access_state, updated_at) + ON users TO auth_app; + +-- --------------------------------------------------------------------------------------------- +-- COMMENT'ы: переписываем то, что 004 сделала неверным в 001 +-- --------------------------------------------------------------------------------------------- +COMMENT ON TABLE users IS + 'Единый реестр людей для «Меры» (trade-in) и «Птицы» (Site Finder): идентичность И ' + 'полномочия. Решение владельца продукта 2026-07-31 — ПОЛНЫЙ переезд: tradein_users ' + 'удаляется, второго реестра не будет. Прежняя формулировка («роли остаются в продуктовых ' + 'БД», 001) отменена миграцией 004 — см. её заголовок.'; + +COMMENT ON COLUMN users.role IS + 'Полномочия: admin | manager | employee. Зеркало tradein_users.role (tradein м.192) — код ' + '«Меры» должен переехать на эту таблицу без правок в проверках роли. DEFAULT намеренно нет: ' + 'роль выбирает тот, кто заводит человека; INSERT без роли обязан падать, а не создавать ' + 'аккаунт с полномочиями «по умолчанию».'; + +COMMENT ON COLUMN users.manager_id IS + 'Self-FK на users(id), ON DELETE SET NULL: удаление менеджера оставляет его сотрудников в ' + 'реестре без привязки, а не сносит их каскадом. NULL для admin/manager (top-level роли, ' + 'констрейнт users_role_manager_hierarchy_ck) и для employee без организации. ' + 'ИНВАРИАНТЫ, КОТОРЫЕ БД НЕ ПРОВЕРЯЕТ (обязан держать КАЖДЫЙ пишущий сюда код — реестр общий ' + 'для «Меры» и «Птицы»): цель ссылки обязана иметь role = ''manager''; циклы (A→B, B→A) ' + 'запрещены — рекурсивный обход иерархии на них зациклится. Схемой ловится только ссылка ' + 'строки на саму себя (users_manager_not_self_ck): остальное требует чтения другой строки и ' + 'строчным CHECK не выражается. Отсутствие проверки в БД — не разрешение.'; + +COMMENT ON COLUMN users.access_state IS + 'Состояние доступа, три значения — заменило булев is_active (миграция 004). ' + 'active: вход разрешён. ' + 'trial_expired: пробный период истёк — при ВЕРНОМ пароле логин отвечает 403 с отдельным ' + 'кодом и текстом «пробный доступ закончился», сессия не выдаётся (аккаунт видит осмысленный ' + 'экран, а не «неверный пароль»). ' + 'disabled: доступ закрыт — generic 401, неотличимо от неверного пароля. ' + 'Неверный пароль в любом состоянии → generic 401: иначе отдельный ответ для trial_expired ' + 'стал бы оракулом существования логина. Булев флаг схлопывал бы trial_expired и disabled в ' + 'одно значение, и trial-экран исчез бы молча. ' + 'ИНВАРИАНТ ДЛЯ API (в БД не выразим): перевод ПОСЛЕДНЕГО active-админа в любое другое ' + 'состояние обязан отклоняться на уровне приложения. Констрейнт с role не связан, ' + 'UPDATE ... SET access_state = ''disabled'' WHERE username = ''admin'' в БД проходит, а после ' + 'перехода на единую форму входа это self-lockout: не остаётся аккаунта, способного открыть ' + 'доступ обратно через UI, восстановление — только psql на прод-БД. Сегодня путь закрыт тем, ' + 'что «Команда» не отдаёт строки с role = ''admin'' никому (team.py); любой новый админский ' + 'экран, пишущий access_state, обязан проверку восстановить.'; + +COMMENT ON CONSTRAINT users_role_manager_hierarchy_ck ON users IS + 'admin/manager обязаны иметь manager_id IS NULL — это top-level роли, «начальника» у них в ' + 'этой модели нет (зеркало tradein м.192). Для employee manager_id любой, включая NULL ' + '(свободный слот без организации допустим).'; + +COMMENT ON CONSTRAINT users_manager_not_self_ck ON users IS + 'Строка не может быть собственным менеджером (manager_id <> id). Ловит опечатку/копипасту ' + 'id при ручной правке и у второго потребителя реестра («Птица»), где валидации «Команды» ' + 'нет. Взаимные пары и ссылку на не-менеджера строчный CHECK не ловит — см. COMMENT к ' + 'users.manager_id.'; + +COMMENT ON CONSTRAINT users_access_state_ck ON users IS + 'Фиксирует ровно три состояния доступа. Расширение — новой миграцией с ALTER этого ' + 'констрейнта; тип text + CHECK выбран вместо enum именно ради дешёвого расширения.'; + +COMMIT; diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index cdcc39d5..61180727 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -21,7 +21,7 @@ | **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner | | **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) | | **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) | -| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb | +| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb | | **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек | | **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy | | **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton | @@ -62,6 +62,7 @@ | `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) | | `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** | | `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** | +| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** | | `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** | | `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** | | `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** | @@ -76,7 +77,6 @@ |---|---|---| | `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** | | `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** | -| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** | | `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** | | `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) | | `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** | @@ -146,14 +146,14 @@ bcrypt-хеши — односторонние, не plaintext-секреты, 3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`. 4. Vault entry. -### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`) +### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`) **Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии). -1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт). +1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт). 2. Где живёт: - `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`. - - `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI). + - `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI). 3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein). 4. Vault entry. diff --git a/ops/db-bootstrap/create_auth_db.sql b/ops/db-bootstrap/create_auth_db.sql new file mode 100644 index 00000000..b94dd4ef --- /dev/null +++ b/ops/db-bootstrap/create_auth_db.sql @@ -0,0 +1,67 @@ +-- Создание БД `auth` — единого хранилища доступов «Меры» и «Птицы» (идемпотентно). +-- +-- Applied by .forgejo/workflows/deploy.yml ПЕРЕД миграциями data/sql/auth/*.sql: +-- docker compose ... exec -T postgres psql -U "$POSTGRES_USER" -d postgres \ +-- -v ON_ERROR_STOP=on < ops/db-bootstrap/create_auth_db.sql +-- Подключение обязательно к БД `postgres`: нельзя создать базу, находясь в ней самой. +-- +-- ПОЧЕМУ ЭТО НЕ МИГРАЦИЯ: +-- CREATE DATABASE запрещён внутри транзакционного блока, а .claude/rules/sql.md требует +-- от каждого файла в data/sql обёртки BEGIN/COMMIT. Плюс миграции `auth` по определению +-- выполняются уже ВНУТРИ БД `auth` — то есть создать её собой они не могут. Отсюда +-- отдельный bootstrap-шаг, по образцу scripts/bootstrap_glitchtip.sh (там так же +-- заводится вторая БД на этом же сервере). +-- +-- ПОЧЕМУ \gexec, А НЕ DO-БЛОК: +-- DO-блок — это функция, она выполняется внутри транзакции, значит CREATE DATABASE в ней +-- недопустим. \gexec строит текст команды на стороне клиента и отправляет её отдельным +-- стейтментом. Если WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и +-- молча ничего не делает — это и даёт идемпотентность без ошибки на повторном прогоне. +-- ON_ERROR_STOP=on распространяется и на команды, выполненные через \gexec. +-- +-- ВЛАДЕЛЕЦ БД — $POSTGRES_USER (суперюзер кластера), НЕ auth_app. Владелец объекта имеет на +-- него все права в обход GRANT'ов; если бы БД и таблицы принадлежали прикладной роли, +-- точечные гранты в data/sql/auth/002_auth_app_role.sql были бы декорацией. Роль auth_app +-- создаётся миграцией 002 и получает только нужные DML-права. +-- +-- TEMPLATE template0 — сознательно, а не template1 (шаблон по умолчанию): template0 +-- гарантированно пуст и неизменяем, а в template1 любой может доустановить расширения или +-- объекты, и они молча окажутся в хранилище паролей. На образе postgis:16-3.4 сегодня +-- postgis лежит в template_postgis, а template1 чист (проверено локально на том же образе), +-- но полагаться на это как на инвариант незачем — template0 снимает вопрос навсегда. +-- ENCODING 'UTF8' указан явно (кластер и так UTF8 — вся кириллица gendesign лежит в нём), +-- чтобы кодировка хранилища логинов не зависела от того, с какими аргументами когда-нибудь +-- пересоздадут кластер. +-- +-- Пароля в этом файле нет и быть не может: роль создаётся passwordless в миграции 002, +-- пароль ставится отдельным шагом из env (ops/db-bootstrap/set_auth_app_password.sql). + +SELECT 'CREATE DATABASE auth TEMPLATE template0 ENCODING ''UTF8'';' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'auth') +\gexec + +-- Единственная преграда для «любая login-роль кластера (glitchtip, tradein_fdw_reader, +-- gendesign_reader) открывает сессию в хранилище паролей»: по умолчанию PostgreSQL выдаёт +-- CONNECT роли PUBLIC при создании БД. +-- +-- ДУБЛЬ С data/sql/auth/002_auth_app_role.sql — НАМЕРЕННЫЙ, не копипаста. Инвариант держится +-- в двух местах, потому что у файлов разный жизненный цикл: +-- * здесь (bootstrap) — ради ПЕРЕПРИМЕНЯЕМОСТИ: этот файл гоняется на КАЖДОМ деплое, там же, +-- где создаётся БД. Если `auth` восстановят из дампа или пересоздадут в обход миграций, +-- база появится с дефолтным PUBLIC-CONNECT, а 002 уже числится применённой в +-- _schema_migrations и второй раз не выполнится — REVOKE молча не вернётся. +-- * в 002 — ради САМОДОСТАТОЧНОСТИ миграции: применённая на пустую БД (scratch/staging, +-- ручной psql -f) она обязана давать полный периметр прав без чтения bootstrap-файлов. +-- Удалять любую из двух копий нельзя: каждая закрывает сценарий, который другая не покрывает. +-- +-- Выполнимо из подключения к БД `postgres` (мы именно в ней): права на объект DATABASE живут +-- в pg_database.datacl — это общий на кластер каталог, не локальный для БД, в отличие от +-- грантов на таблицы/схемы. Проверено эмпирически на postgis:16-3.4 (REVOKE из сессии в +-- `postgres` по другой БД убирает `=Tc/` из datacl, has_database_privilege('public', …, +-- 'CONNECT') → false). Команда идемпотентна — повторный прогон бесплатен. +REVOKE ALL ON DATABASE auth FROM PUBLIC; + +COMMENT ON DATABASE auth IS + 'Единое хранилище доступов: «Мера» (trade-in) и «Птица» (Site Finder). Схема — ' + 'data/sql/auth/*.sql, применяется отдельным циклом миграций в .forgejo/workflows/deploy.yml ' + '(таблица _schema_migrations живёт внутри этой же БД).'; diff --git a/ops/db-bootstrap/set_auth_app_password.sql b/ops/db-bootstrap/set_auth_app_password.sql new file mode 100644 index 00000000..bd6df39b --- /dev/null +++ b/ops/db-bootstrap/set_auth_app_password.sql @@ -0,0 +1,50 @@ +-- Set auth_app password from env. +-- Applied by .forgejo/workflows/deploy.yml after auth DB migrations: +-- psql -v pw="$AUTH_DB_PASSWORD" < ops/db-bootstrap/set_auth_app_password.sql +-- Источник переменной: AUTH_DB_PASSWORD из /opt/gendesign/backend/.env.runtime (chmod 600, +-- вне git). Зеркало паттерна ops/db-bootstrap/set_tradein_fdw_password.sql и +-- tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql. +-- +-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет (миграция +-- data/sql/auth/002_auth_app_role.sql могла ещё не примениться на первом деплое). +-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной. +-- +-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками. +-- +-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted блока ($$...$$) +-- — это правило psql, не bug. Поэтому password передаём в DO через сессионный GUC +-- (set_config), который psql интерполирует ВНЕ dollar quote, и читаем внутри через +-- current_setting(). По той же причине файл подаётся через stdin, а НЕ через `psql -c`. +-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на +-- "syntax error at or near ':'" именно на этом. +-- +-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное значение. Без +-- `\o /dev/null` psql напечатал бы пароль на stdout → leak в Forgejo Actions deploy logs +-- (retained, visible всем с repo read access). Поэтому оба set_config обёрнуты в +-- `\o /dev/null` / `\o` — глушится только их вывод, NOTICE из DO block (сигнал +-- идемпотентности) остаётся видимым. +-- +-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$). Корректный +-- rollback — unset AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime на VPS, deploy.yml +-- тогда пропустит этот шаг полностью (роль останется без пароля = логин по паролю невозможен). + +\o /dev/null +SELECT set_config('app.auth_pw', :'pw', false); +\o + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN + EXECUTE format('ALTER ROLE auth_app WITH PASSWORD %L', current_setting('app.auth_pw')); + RAISE NOTICE 'auth_app password set'; + ELSE + RAISE NOTICE 'auth_app role missing — migration data/sql/auth/002_auth_app_role.sql not applied yet'; + END IF; +END $$; + +-- Clear GUC after use (defense-in-depth — не оставляем password в session state даже на +-- short connection). Same \o trick — set_config return value is empty string here, но лишний +-- row в stdout всё равно не нужен. +\o /dev/null +SELECT set_config('app.auth_pw', '', false); +\o diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh new file mode 100644 index 00000000..cda7725a --- /dev/null +++ b/scripts/smoke-mera-perimeter.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# Проверяет инварианты периметра (см. корневой Caddyfile): +# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). +# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 — +# политика ПДн, на которую ссылается футер. +# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 — +# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево +# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без. +# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B +# закрыты. Именно API, а не страница: см. комментарий у проверки ниже. +# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder). +# 5. merahome.ru и meraotsenka.ru отдают 301 на канонический meraocenka.ru. +# +# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS +# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока +# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake +# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от +# DNS нового домена и обязаны быть зелёными всегда. +# +# Запуск вручную: +# bash scripts/smoke-mera-perimeter.sh +# Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron). +set -uo pipefail + +BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}" +BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}" + +fail=0 + +check() { + local desc="$1" url="$2" expected="$3" + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null) + if [ "$code" = "$expected" ]; then + echo "PASS: $desc ($url -> $code)" + else + echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" + fail=1 + fi +} + +echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" + +# 1. Публичный домен отдаёт 200 анонимно. +check "meraocenka.ru root — public 200" "$BASE_MERA/" 200 + +# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер. +# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно +# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile, +# обязательный по 152-ФЗ документ станет недоступен с публичной страницы. +check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200 + +# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. +check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 + +# 2b. Те же B2B-пути в basePath-форме — 404. Это регресс-тест именно на +# matcher `handle /trade-in/mera-public/*`: расширь его случайно до +# `/trade-in/*` — и B2B-дерево уедет наружу через публичный домен, а +# проверка 2 (/v2 без префикса) этого НЕ заметит. +check "meraocenka.ru/trade-in/v2 — B2B path must 404" "$BASE_MERA/trade-in/v2" 404 +check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)" "$BASE_MERA/trade-in/api/v1/me" 404 + +# 2c. Статика проксируется ТОЛЬКО из _next/static/*. Оптимизатор картинок +# /_next/image на лэндинге не нужен (next/image там не импортируется) и +# наружу не открыт — иначе аноним получил бы CPU-нагрузку по запросу. +# Ловит расширение матчера обратно до `/trade-in/_next/*`. +check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 + +# 3. B2B-данные trade-in по-прежнему закрыты анониму. +# +# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200. +# После #2555/#2558 trade-in ушёл с Caddy basic_auth на собственный логин: +# страница рендерится анониму, а RouteGuard уже на клиенте уводит на /login. +# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при +# открытом наружу бэкенде. +check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401 +check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401 +check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401 + +# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга +# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается +# на уровне Caddy до бэкенда). +check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401 + +# 5. Домены-спутники ведут на канонический (301, без следования редиректу — +# curl без -L, поэтому ждём именно код редиректа, а не 200 конечной страницы). +# Как и проверки 1-2, требуют DNS + выпущенного сертификата. +check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301 +check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301 + +echo "========================================" +if [ "$fail" -eq 0 ]; then + echo "ALL CHECKS PASSED" +else + echo "SOME CHECKS FAILED — see FAIL lines above" +fi + +exit "$fail" diff --git a/tradein-mvp/.env.example b/tradein-mvp/.env.example index 7bacad4a..456ebecd 100644 --- a/tradein-mvp/.env.example +++ b/tradein-mvp/.env.example @@ -6,12 +6,6 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"] ENVIRONMENT=dev -# Yandex Geocoder API key (25k req/day free tier). -# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py). -# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ -# и упадут с SystemExit без него. -YANDEX_GEOCODER_API_KEY= - # DaData /clean/address — обогащение target адреса в estimate flow (PR Q1). # Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро. # Demo tier: 100 req/день — хватит для тестов и low-traffic prod. diff --git a/tradein-mvp/DEPLOY.md b/tradein-mvp/DEPLOY.md index 6b9fdc2c..d8012ea5 100644 --- a/tradein-mvp/DEPLOY.md +++ b/tradein-mvp/DEPLOY.md @@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment shell-скриптом deploy через `source .env.runtime` перед `compose up`. 2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда - попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY` — - всё, что нужно scripts/backfill_house_coords.py и application code внутри + попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри контейнера. ```bash @@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32> TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru -YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает # Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator). # Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503. @@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex> ```bash # /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые -# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*). +# читаются ВНУТРИ container'а (app/*, scripts/*.py). # Может быть симлинком на ../.env.runtime если переменные совпадают: # ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime -YANDEX_GEOCODER_API_KEY= COOKIE_ENCRYPTION_KEY=<64-char hex> GENDESIGN_FDW_PASSWORD= GLITCHTIP_DSN= @@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime < str: """Чистим address для геокодера. Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...' - мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок. + мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса + не используют — адрес остаётся без изменений. """ main = addr.split(" · ")[0].strip() return main or addr @@ -260,7 +268,7 @@ async def geocode_missing( ) -> dict: """Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address). - target=listings (по умолч.) — объявления Cian/N1; target=deals — сделки Росреестра. + target=listings (по умолч.) — объявления; target=deals — сделки Росреестра. Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron `curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в `remaining`, cron вызывает в цикле пока `remaining` > 0. @@ -269,17 +277,13 @@ async def geocode_missing( адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается. geom обновляется автоматически триггером. """ - # Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса. - extra_filter = ( - "AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'" - if target == "listings" - else "" - ) + # Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса. + extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else "" rows = ( db.execute( text( f""" - SELECT id, address + SELECT id, address, city FROM {target} WHERE lat IS NULL AND COALESCE(address, '') != '' @@ -313,7 +317,20 @@ async def geocode_missing( ) break clean = _clean_address_for_geocode(row["address"]) - result = await geocode(clean, db) + # city (#2594 шаг 2/3) — известен вызывающему коду через listings.city + # (миграция 196) / deals.city (миграция 177), проставляется из контекста + # развёртки/импорта. Прокидываем как city_hint, а не полагаемся на то, что + # геокодер угадает город по тексту address (голый "ул. Победы, 30" без + # города в тексте иначе уходит в Екатеринбург). + # + # known_city_hint (#2603) — гейт по словарю городов области: при + # target="deals" сюда приходит росреестровое поле, в хвосте которого + # лежат не-города («Бессонова», «Билейский рыбопитомник»), а мусорный + # хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос + # провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у + # scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py. + city = known_city_hint(row.get("city")) + result = await geocode(clean, db, city_hint=city) if result is None: # Помечаем что пробовали — иначе ретрай на каждом cron. db.execute( @@ -1577,8 +1594,22 @@ def update_schedule( """UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT).""" from app.services.scheduler import compute_next_run_at - # Compute new next_run_at если window изменился — recompute, иначе keep existing - next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour) + # #2674: такт берётся из default_params — ровно как его читает планировщик + # (_claim_run/_defer_next_run_at). Без него compute_next_run_at падал на default=1 и + # ЛЮБОЕ сохранение сбивало источник на «завтра»: недельный avito_full_load после + # правки окна побежал бы через сутки. На суточных источниках баг был невидим — + # для них «завтра» и есть правильный ответ. + # None-safe так же, как в scheduler: `"interval_days": null` в jsonb → 1, не TypeError. + _interval_days = payload.default_params.get("interval_days") + interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1 + + # Явно заданный оператором момент уважается как есть (в т.ч. в прошлом — «запустить + # сейчас»). Иначе считаем от такта. + next_at = payload.next_run_at or compute_next_run_at( + payload.window_start_hour, + payload.window_end_hour, + interval_days=interval_days, + ) row = ( db.execute( @@ -1592,7 +1623,22 @@ def update_schedule( window_start_hour = EXCLUDED.window_start_hour, window_end_hour = EXCLUDED.window_end_hour, default_params = EXCLUDED.default_params, - next_run_at = EXCLUDED.next_run_at, + -- #2674: не двигаем уже назначенный запуск, если двигать не за чем. + -- Раньше next_run_at перезаписывался ВСЕГДА, поэтому правка соседнего + -- поля (enabled, request_delay_sec в params) заново разыгрывала момент + -- внутри окна и сдвигала прогон. Сохраняем существующий только когда он + -- ещё в будущем И ни окно, ни такт не менялись — тогда пересчёт дал бы + -- то же самое окно, только с другим random-смещением. + next_run_at = CASE + WHEN CAST(:explicit AS boolean) THEN EXCLUDED.next_run_at + WHEN scrape_schedules.next_run_at > NOW() + AND scrape_schedules.window_start_hour = EXCLUDED.window_start_hour + AND scrape_schedules.window_end_hour = EXCLUDED.window_end_hour + AND COALESCE(scrape_schedules.default_params ->> 'interval_days', '1') + = COALESCE(EXCLUDED.default_params ->> 'interval_days', '1') + THEN scrape_schedules.next_run_at + ELSE EXCLUDED.next_run_at + END, updated_at = NOW() RETURNING id, source, enabled, window_start_hour, window_end_hour, default_params, last_run_id, last_run_at, next_run_at, updated_at @@ -1605,6 +1651,7 @@ def update_schedule( "we": payload.window_end_hour, "params": json.dumps(payload.default_params, ensure_ascii=False), "next_at": next_at, + "explicit": payload.next_run_at is not None, }, ) .mappings() @@ -2169,16 +2216,33 @@ async def scrape_house_imv_backfill( ) -# ── Единая scrapers-страница: unified runs + health + rotate-ip (epic) ──────── +# ── Единая scrapers-страница: unified runs + health (epic) ─────────────────── +# rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая подписка (#2613). class UnifiedScrapeRunRow(BaseModel): - """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).""" + """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче). + + #2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом + 'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и + таблица подписывала им прогоны, которые никаким sweep не были — + proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало, + называет `source`. + """ run_id: int source: str - run_type: str | None = None status: str + # #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была + # пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор + # видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену + # не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а + # has_running_run перестал бы держать single-run guard. + cancellable: bool = False + # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или + # 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только + # «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов. + ban_kind: str | None = None params: dict | None = None counters: dict | None = None total_seen: int | None = None @@ -2194,6 +2258,12 @@ class UnifiedScrapeRunsResponse(BaseModel): rows: list[UnifiedScrapeRunRow] +class ScrapeRunSourcesResponse(BaseModel): + """Список source'ов для фильтра истории прогонов — из данных, не из литерала.""" + + sources: list[str] + + class BrowserHealth(BaseModel): reachable: bool browsers: dict[str, bool] = Field(default_factory=dict) @@ -2213,33 +2283,23 @@ class ScraperHealthResponse(BaseModel): providers: list[ProviderHealth] -class RotateIpResponse(BaseModel): - ok: bool - new_ip: str | None = None - reason: str | None = None - - _ROTATABLE_SOURCES = ("avito", "cian", "yandex") def _provider_proxy_url(source: str) -> str | None: - """Effective proxy URL для source (учитывает property-fallback в settings).""" + """Effective proxy URL для source (учитывает property-fallback в settings). + + #2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url + (per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая + mobileproxy-подписка, #2613). + """ return { - "avito": settings.avito_proxy_url, + "avito": settings.scraper_proxy_url, "cian": settings.cian_proxy_url, "yandex": settings.yandex_proxy_url, }.get(source) -def _provider_rotate_url(source: str) -> str | None: - """changeip-URL для source (None → auto-rotate прокси без ручной ротации).""" - return { - "avito": settings.avito_proxy_rotate_url, - "cian": settings.cian_proxy_rotate_url, - "yandex": settings.yandex_proxy_rotate_url, - }.get(source) - - def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]: """Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port).""" if not proxy_url: @@ -2257,7 +2317,11 @@ def list_scrape_runs_unified( db: Annotated[Session, Depends(get_db)], source: Annotated[str | None, Query()] = None, status: Annotated[ - Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query() + # 'skipped' (#2658) — пропущенное расписание; без него оператор не может + # спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной + # поверхности, построенной ровно для этого вопроса). + Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None, + Query(), ] = None, limit: Annotated[int, Query(ge=1, le=200)] = 50, offset: Annotated[int, Query(ge=0)] = 0, @@ -2269,7 +2333,7 @@ def list_scrape_runs_unified( Query: source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...). - status — опц. фильтр (done/running/banned/zombie/failed/cancelled). + status — опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped). limit — default 50, max 200. offset — default 0. """ @@ -2284,8 +2348,9 @@ def list_scrape_runs_unified( UnifiedScrapeRunRow( run_id=r["run_id"], source=r["source"], - run_type=r.get("run_type"), status=r["status"], + cancellable=runs_mod.honors_cancel(str(r["source"])), + ban_kind=r.get("ban_kind"), params=r.get("params"), counters=r.get("counters"), total_seen=r.get("total_seen"), @@ -2300,6 +2365,21 @@ def list_scrape_runs_unified( ) +@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse) +def list_scrape_run_sources( + db: Annotated[Session, Depends(get_db)], +) -> ScrapeRunSourcesResponse: + """Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs. + + #2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в + таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением — каждый + пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том + числе) были недоступны для вопроса «что там происходит». Список берётся из + данных: новый source появляется в фильтре сам, без правки кода. + """ + return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db)) + + async def _probe_browser_health() -> BrowserHealth: """GET tradein-browser /health (timeout 5с). reachable=False при ошибке.""" url = f"{settings.browser_http_endpoint.rstrip('/')}/health" @@ -2338,8 +2418,10 @@ async def scraper_health() -> ScraperHealthResponse: - fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser). - browser: GET tradein-browser /health (reachable + per-browser ready-флаги). - - providers: для avito/cian/yandex — proxy host/port, rotate_supported, - best-effort current_ip (параллельный пробинг через прокси на ipify). + - providers: для avito/cian/yandex — proxy host/port, rotate_supported + (#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый + аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611, + не per-provider-source), best-effort current_ip (пробинг через прокси на ipify). Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с. """ @@ -2359,7 +2441,7 @@ async def scraper_health() -> ScraperHealthResponse: source=source, proxy_host=host, proxy_port=port, - rotate_supported=bool(_provider_rotate_url(source)), + rotate_supported=False, current_ip=ip_by_source[source], ) ) @@ -2371,46 +2453,6 @@ async def scraper_health() -> ScraperHealthResponse: ) -@router.post("/scraper/{source}/rotate-ip", response_model=RotateIpResponse) -async def rotate_proxy_ip( - source: Literal["avito", "cian", "yandex"], -) -> RotateIpResponse: - """Сменить мобильный exit-IP провайдера через changeip-ссылку (mobileproxy). - - Зеркалит логику AvitoScraper._rotate_ip (GET rotate_url + &format=json), но БЕЗ - settle-sleep — API сразу возвращает ответ changeip. Если rotate_url не задан — - прокси с авто-ротацией (свежий IP на новое соединение), ручная ротация не нужна. - """ - rotate_url = _provider_rotate_url(source) - if not rotate_url: - return RotateIpResponse(ok=False, reason="no rotate url (auto-rotate proxy)") - - sep = "&" if "?" in rotate_url else "?" - try: - async with httpx.AsyncClient(timeout=20.0) as client: - resp = await client.get(f"{rotate_url}{sep}format=json") - resp.raise_for_status() - try: - data = resp.json() - except Exception: - data = {} - except Exception: - # НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения - # несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API- - # ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в - # HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся - # в логах (exc_info=True) для диагностики; наружу — только нейтральный reason. - logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True) - return RotateIpResponse(ok=False, reason="changeip request failed") - - # changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый). - new_ip = None - if isinstance(data, dict): - new_ip = data.get("new_ip") or data.get("ip") or data.get("proxy_ip") - logger.info("rotate-ip: source=%s new_ip=%s", source, new_ip) - return RotateIpResponse(ok=True, new_ip=str(new_ip) if new_ip else None) - - # ── Pacing live-регулятор (GET/PUT /scraper/pacing) ────────────────────────── @@ -2506,6 +2548,12 @@ async def update_scraper_pacing( class SourceCoverage(BaseModel): source: str active_count: int + # #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших, + # а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530 + # активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как + # «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом + # рядом — тогда «активно» перестаёт читаться как «живо». + stale_count: int fields: dict[str, float] # field_name -> fill% (0..100, round 1) @@ -2520,6 +2568,9 @@ class HousesCoverage(BaseModel): class DataQualityResponse(BaseModel): sources: list[SourceCoverage] houses: HousesCoverage + # Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI + # подписывал число, а не хардкодил порог у себя вторым определением. + stale_days: int # Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL). @@ -2549,6 +2600,10 @@ def get_data_quality( living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations. houses: total, avito_validated_at%, rating_score%, house_type%. house_reviews: общий count. + + #2660: рядом с active_count отдаётся stale_count — сколько из «активных» не + виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе + (stale_days), чтобы UI не заводил второе определение. """ # Строим single-pass SELECT для listings полей через FILTER-агрегаты. # Структура: COUNT(*) FILTER (WHERE ) / NULLIF(COUNT(*), 0) * 100 @@ -2556,10 +2611,17 @@ def get_data_quality( filter_exprs = ", ".join( f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS ) + # last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не + # виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных + # строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка + # должна называть ровно её. sql_listings = text(f""" SELECT source, COUNT(*) AS active_count, + COUNT(*) FILTER ( + WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval + ) AS stale_count, {filter_exprs} FROM listings WHERE is_active = true @@ -2567,7 +2629,7 @@ def get_data_quality( ORDER BY source """) - rows = db.execute(sql_listings).mappings().all() + rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all() sources: list[SourceCoverage] = [] for row in rows: @@ -2581,6 +2643,7 @@ def get_data_quality( SourceCoverage( source=row["source"], active_count=int(row["active_count"]), + stale_count=int(row["stale_count"] or 0), fields=fields, ) ) @@ -2608,7 +2671,7 @@ def get_data_quality( reviews_count=reviews_count, ) - return DataQualityResponse(sources=sources, houses=houses) + return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS) # ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ─────────────────── @@ -2679,6 +2742,52 @@ class ProxyBulkResponse(BaseModel): updated: int +class ProxySourceBan(BaseModel): + """Активный бан узла КОНКРЕТНОЙ площадкой (#2600 п.2, scrape_proxy_source_bans).""" + + source: str + banned_until: str + ban_count: int + + +def _fetch_source_bans(db: Session, proxy_ids: list[int]) -> dict[int, list[ProxySourceBan]]: + """Активные (banned_until > now()) баны по источникам для указанных узлов. + + Без этого оператор видит `enabled=true` и не понимает, почему узел не выдаётся + конкретному источнику (#2600 п.2 — бан теперь по паре «узел × источник», а не + глобальное выключение). Истёкшие строки не показываем: они ни на что не влияют, + живут ещё SOURCE_BAN_PURGE_DAYS только как память об эскалации. + """ + if not proxy_ids: + return {} + rows = ( + db.execute( + text( + """ + SELECT proxy_id, source, banned_until, ban_count + FROM scrape_proxy_source_bans + WHERE banned_until > now() + AND proxy_id = ANY(CAST(:ids AS bigint[])) + ORDER BY proxy_id, source + """ + ), + {"ids": proxy_ids}, + ) + .mappings() + .all() + ) + bans: dict[int, list[ProxySourceBan]] = {} + for r in rows: + bans.setdefault(int(r["proxy_id"]), []).append( + ProxySourceBan( + source=r["source"], + banned_until=r["banned_until"].isoformat(), + ban_count=r["ban_count"], + ) + ) + return bans + + class ProxyRow(BaseModel): id: int label: str | None @@ -2687,6 +2796,7 @@ class ProxyRow(BaseModel): provider_affinity: str rotate_url: str | None # маскированный enabled: bool + disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем) consecutive_fails: int exit_ip: str | None latency_ms: int | None @@ -2699,6 +2809,8 @@ class ProxyRow(BaseModel): expires_at: str | None created_at: str | None updated_at: str | None + # #2600 п.2: активные баны площадками. Пустой список = узел выдаётся всем источникам. + source_bans: list[ProxySourceBan] = Field(default_factory=list) @router.post("/proxies/bulk", response_model=ProxyBulkResponse) @@ -2711,8 +2823,18 @@ def bulk_upsert_proxies( Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?, "label"?, "geo"?, "operator"?}, ...]}. - Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled=true, - label/geo/operator обновляются если переданы). Новый → INSERT. + Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled, + label/geo/operator обновляются если переданы). Новый → INSERT (enabled=true, + disabled_reason=NULL — новый прокси не может быть "выключен вручную"). + + enabled на UPDATE-ветке НЕ безусловный (#2610): если у существующей строки + disabled_reason НЕ NULL (оператор снял узел с ротации вручную), bulk-upsert + (например повторный прогон загрузчика с тем же url) не должен тихо вернуть + его в строй — тот же класс бага, что чинили в mark_health. enabled=true + ставится, только если disabled_reason IS NULL; сам disabled_reason bulk + не трогает (эта ручка не умеет ни ставить, ни снимать ручной флаг — это + PATCH /proxies/{id}, см. patch_proxy). + Валидация provider_affinity/kind по whitelist на уровне Pydantic → 422. Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса @@ -2738,7 +2860,10 @@ def bulk_upsert_proxies( label = COALESCE(EXCLUDED.label, scrape_proxies.label), geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo), operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator), - enabled = true, + enabled = CASE + WHEN scrape_proxies.disabled_reason IS NULL THEN true + ELSE scrape_proxies.enabled + END, updated_at = now() RETURNING (xmax = 0) AS was_inserted """ @@ -2771,6 +2896,9 @@ def list_proxies( """Список прокси со статусами. Пароли в url/rotate_url маскируются. Фильтры: provider (=provider_affinity), enabled. Без фильтров — все. + + source_bans — активные баны узла площадками (#2600 п.2): узел может быть + enabled=true и при этом не выдаваться конкретному источнику. """ clauses: list[str] = [] params: dict[str, Any] = {} @@ -2787,8 +2915,9 @@ def list_proxies( text( f""" SELECT id, label, url, kind, provider_affinity, rotate_url, enabled, - consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at, - leased_by, leased_at, geo, operator, expires_at, created_at, updated_at + disabled_reason, consecutive_fails, exit_ip, latency_ms, + last_check_at, last_ok_at, leased_by, leased_at, geo, operator, + expires_at, created_at, updated_at FROM scrape_proxies {where} ORDER BY provider_affinity, id @@ -2804,6 +2933,8 @@ def list_proxies( def _iso(v: Any) -> str | None: return v.isoformat() if v is not None else None + bans = _fetch_source_bans(db, [int(r["id"]) for r in rows]) + return [ ProxyRow( id=r["id"], @@ -2813,6 +2944,7 @@ def list_proxies( provider_affinity=r["provider_affinity"], rotate_url=_mask_proxy_url(r["rotate_url"]), enabled=r["enabled"], + disabled_reason=r["disabled_reason"], consecutive_fails=r["consecutive_fails"], exit_ip=r["exit_ip"], latency_ms=r["latency_ms"], @@ -2825,6 +2957,7 @@ def list_proxies( expires_at=_iso(r["expires_at"]), created_at=_iso(r["created_at"]), updated_at=_iso(r["updated_at"]), + source_bans=bans.get(int(r["id"]), []), ) for r in rows ] @@ -2832,6 +2965,18 @@ def list_proxies( class ProxyPatch(BaseModel): enabled: bool + reason: str | None = Field( + default=None, + max_length=500, + description=( + "Причина ручного выключения (#2610). Используется только когда enabled=false; " + "при отсутствии подставляется дефолтный текст. Игнорируется при enabled=true — " + "включение всегда сбрасывает disabled_reason в NULL." + ), + ) + + +_DEFAULT_MANUAL_DISABLE_REASON = "manually disabled via admin API" @router.patch("/proxies/{proxy_id}", response_model=ProxyRow) @@ -2840,20 +2985,40 @@ def patch_proxy( payload: ProxyPatch, db: Annotated[Session, Depends(get_db)], ) -> ProxyRow: - """Enable/disable одного прокси по id. 404 если не найден.""" + """Enable/disable одного прокси по id. 404 если не найден. + + #2610: разводит "ручное выключение оператором" от "авто-выключение пулом". + enabled=false → disabled_reason ставится (payload.reason либо дефолтный текст) — + mark_health(ok=True) больше не воскресит узел молча первой успешной ipify-пробой. + enabled=true → disabled_reason ОБЯЗАТЕЛЬНО сбрасывается в NULL — иначе узел, + однажды выключенный руками, никогда больше не участвовал бы в авто-восстановлении + (см. proxy_pool.mark_health). + """ row = ( db.execute( text( """ UPDATE scrape_proxies - SET enabled = :enabled, updated_at = now() + SET enabled = :enabled, + disabled_reason = CASE + WHEN CAST(:enabled AS boolean) THEN NULL + ELSE COALESCE(CAST(:reason AS text), disabled_reason, + CAST(:default_reason AS text)) + END, + updated_at = now() WHERE id = :id RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled, - consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at, - leased_by, leased_at, geo, operator, expires_at, created_at, updated_at + disabled_reason, consecutive_fails, exit_ip, latency_ms, + last_check_at, last_ok_at, leased_by, leased_at, geo, operator, + expires_at, created_at, updated_at """ ), - {"enabled": payload.enabled, "id": proxy_id}, + { + "enabled": payload.enabled, + "reason": payload.reason, + "default_reason": _DEFAULT_MANUAL_DISABLE_REASON, + "id": proxy_id, + }, ) .mappings() .fetchone() @@ -2861,6 +3026,18 @@ def patch_proxy( if row is None: raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found") db.commit() + if payload.enabled: + # Ручное включение = чистый лист, как и обнуление disabled_reason выше (#2610). + # Иначе узел вернулся бы enabled=true, но по-прежнему невыдаваемым источникам с + # активным баном — и оператор не имел бы способа снять ложный бан (#2600 п.2). + clear_source_bans(db, proxy_id, reason="manual enable via admin API") + if not payload.enabled: + logger.info( + "proxy_pool: proxy id=%d manually disabled via admin API (reason=%r) — " + "auto-revive suspended until re-enabled (#2610)", + proxy_id, + row["disabled_reason"], + ) def _iso(v: Any) -> str | None: return v.isoformat() if v is not None else None @@ -2873,6 +3050,7 @@ def patch_proxy( provider_affinity=row["provider_affinity"], rotate_url=_mask_proxy_url(row["rotate_url"]), enabled=row["enabled"], + disabled_reason=row["disabled_reason"], consecutive_fails=row["consecutive_fails"], exit_ip=row["exit_ip"], latency_ms=row["latency_ms"], @@ -2885,4 +3063,46 @@ def patch_proxy( expires_at=_iso(row["expires_at"]), created_at=_iso(row["created_at"]), updated_at=_iso(row["updated_at"]), + source_bans=_fetch_source_bans(db, [int(row["id"])]).get(int(row["id"]), []), + ) + + +# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ─────────────── +# +# Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy, +# changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3). +# Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула +# scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см. +# app.services.proxy_rotation.rotate_proxy. + + +class ProxyRotateResponse(BaseModel): + ok: bool + reason: str | None = None + new_ip: str | None = None + rotations_remaining_today: int + + +@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse) +async def rotate_pool_proxy( + proxy_id: int, + db: Annotated[Session, Depends(get_db)], +) -> ProxyRotateResponse: + """Ручная ротация exit-IP одного прокси пула (#2600 п.5). + + Делегирует в app.services.proxy_rotation.rotate_proxy — читает rotate_url + прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token), + проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API. + ok=False — ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит / + провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена. + + ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не + доходит — страница-заглушка отдаёт 200) — только этот ручной вызов. + """ + result = await proxy_rotation_svc.rotate_proxy(db, proxy_id) + return ProxyRotateResponse( + ok=result.ok, + reason=result.reason, + new_ip=result.new_ip, + rotations_remaining_today=result.rotations_remaining_today, ) diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py new file mode 100644 index 00000000..bfe43cf6 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -0,0 +1,354 @@ +"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549). + +Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml). +См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session` +(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in` +это `/trade-in/api/v1/auth/*` снаружи. + +Security: + - Неверные creds (неизвестный username / доступ закрыт / password_hash NULL / + неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем, + существует ли username (user-enumeration защита). + - Состояние доступа проверяется ТОЛЬКО ПОСЛЕ проверки пароля, и осмысленный + ответ (403 «пробный доступ закончился») получает исключительно тот, кто + пароль уже доказал. Ветвление ДО пароля превратило бы отдельный статус в + оракул существования логина: перебором можно было бы перечислить аккаунты, + не зная ни одного пароля (миграция data/sql/auth/004, WHY-2). + - #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно + один раз — для несуществующего username / NULL password_hash сверяем + против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один + раз на импорте модуля), результат игнорируется. Без этого короткое + замыкание (`user is None → сразу 401`) давало наблюдаемую разницу во + времени ответа (~1мс без bcrypt vs ~100-300мс с ним) — классический + timing-oracle для user-enumeration, даже при одинаковом detail-сообщении. + - Rate-limit по (username, IP) — ЖЁСТЧЕ общего `RateLimitMiddleware` + (`/api/*`), т.к. login — типичная brute-force поверхность. Использует + `SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ + length-prefixed (`len(username):username:ip`) — без этого произвольный + username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip) + парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель + в username недостаточно — паразитная граница возможна с обеих сторон). + - Настоящий ПОТОЛОК ТЕМПА — `verify_password_bounded` (#2665): bcrypt считает + 282 мс, и ровно столько же он раньше держал заблокированным единственный + событийный цикл, кладя вместе с логином ВЕСЬ API. Теперь bcrypt крутится в + пуле из `login_password_verify_workers` потоков, а число потоков и есть + потолок (проверок/с не больше workers/282мс). Убрать одно без другого + нельзя: вынос без потолка ускорил бы перебор вчетверо, потолок без выноса + оставил бы отказ в обслуживании. Сверх очереди — 429, не ожидание. + Слоты делятся ПО АДРЕСУ (#2714): один источник не занимает больше половины, + иначе потолок бил и по своим — легитимный вход с верным паролем во время + флуда получал 429 столько раз, сколько пытался. Ключ — IP, поэтому защита + поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси, + общий адрес за NAT, ротация через ботнет) — см. docstring той же функции. + - Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571): + лимит по паре (username, IP) распределённый перебор обходит целиком, просто + меняя адрес. Превышение порога не блокирует вход, а замедляет ответ + (`_throttle_delay_s`) — см. развёрнутое обоснование там же. + - Raw-пароль НИКОГДА не логируется и не попадает в user_events payload — + только username/ip/user_agent/path/method и (для неудач) состояние + счётчика попыток: сколько их за окно и какая задержка применена. +""" + +from __future__ import annotations + +import asyncio +import logging +import secrets +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.password import PasswordVerifyOverloadedError, hash_password, verify_password_bounded +from app.core.ratelimit import SlidingWindowLimiter, _client_ip +from app.services.auth_session import create_session, get_user_by_username, revoke_session +from app.services.identity_store import AccessState, get_identity_db +from app.services.user_events import schedule_event + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит +# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для +# такого случая). Ключ = username+IP: не даёт распределённому brute-force по +# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно), +# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом. +_LOGIN_LIMITER = SlidingWindowLimiter( + limit=settings.login_rate_limit, + window_s=settings.login_rate_limit_window_s, +) + +# Глобальный счётчик неудач НА ИМЯ (#2571) — ключ БЕЗ IP, поэтому попытки со +# всех адресов складываются в один бюджет. Дополняет `_LOGIN_LIMITER`, а не +# заменяет: тот режет частый перебор с одного адреса, этот — редкий, но с +# тысячи адресов (credential stuffing), от которого per-(username, IP) ключ не +# защищает вообще — каждый новый адрес получает свежие login_rate_limit попыток. +# +# Живёт В ПАМЯТИ ПРОЦЕССА — сознательно, а не по недосмотру. Прод-бэкенд +# запущен одним uvicorn-воркером (docker-compose.prod.yml, комментарий над +# `command`: «Single worker сохраняется для предсказуемости»), значит счётчик и +# так глобален, а Redis в auth-пути добавил бы сетевую зависимость там, где её +# падение = либо дыра (fail-open), либо отказ входа (fail-closed). +# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо +# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling +# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба. +# +# ⚠️ `limit` здесь НЕ ПОРОГ и ничего не режет: мы зовём только `record()`, а он +# на лимит не смотрит — считает и отдаёт число попыток в окне. Настоящий порог +# живёт в `_throttle_delay_s`, которая читает настройку на каждом вызове (и +# потому подхватывает monkeypatch в тестах). Значение продублировано сюда ровно +# для того, чтобы `retry_after()` на этом объекте — если его однажды позовут — +# отвечал по тому же числу, а не по случайному. +_USERNAME_FAIL_LIMITER = SlidingWindowLimiter( + limit=settings.login_username_fail_threshold, + window_s=settings.login_username_fail_window_s, +) + +# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля, +# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо +# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы +# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда +# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт. +_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) + +_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль" + +# Единственный ответ логина, который НЕ generic 401: пароль верный, но пробный +# период истёк. `code` — машиночитаемый контракт для фронта (текст можно менять, +# ветку по нему — нет). Потребитель: `loginErrorMessage` в +# tradein-mvp/frontend/src/app/login/page.tsx — читает `detail.code` из +# `HTTPError.body` (frontend/src/lib/api.ts отдаёт тело ответа как есть) и +# показывает экран про пробный период вместо generic «Проверьте подключение». +# Меняешь значение здесь — меняй и там. +_ACCESS_EXPIRED_CODE = "access_expired" +_ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился" + + +class LoginRequest(BaseModel): + # max_length=64 — ровно верхняя граница CHECK'а реестра + # (`users_username_ascii_ck`, data/sql/auth/001), так что живое имя отсечь + # нельзя. Ограничение нужно не валидации ради: сырое имя становится ключом + # ОБОИХ лимитеров, а их `defaultdict` подчищается только при >10000 ключей и + # только от пустых корзин — при окне в час корзины непустые, освобождать + # нечего. Без границы длины килобайтные имена растили бы память ключами. + # Паттерн/минимум длины НЕ дублируем: в режиме `identity_store="tradein"` + # CHECK'а нет и живут не-ASCII имена (см. тест на кириллицу). + username: str = Field(max_length=64) + password: str + + +class LoginResponse(BaseModel): + ok: bool = True + + +def _throttle_delay_s(fails_in_window: int) -> float: + """Насколько задержать ответ на неудачный вход при *fails_in_window* неудачах + по этому имени за окно. 0 — пока порог не перебран. + + Замедление, а НЕ блокировка — намеренно. Жёсткая блокировка учётки после N + неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он + гарантированно выключает вход конкретному человеку (директору, админу) — + отказ в обслуживании дешевле и надёжнее, чем то, от чего блокировка + защищает. Задержка же не отнимает доступ ни у кого: владелец пароля войдёт + с первой попытки, просто ответ на очередную НЕУДАЧУ придёт медленнее. + + Рост удвоением от 1с с потолком `login_username_throttle_max_delay_s`: + первые перебранные попытки почти незаметны, а сотни — упираются в потолок. + Потолок обязателен: без него задержка становится той же блокировкой, только + растянутой во времени. + + Показатель степени зажат (`min(..., 16)`) — это не косметика. `min()` считает + ОБА аргумента до сравнения, поэтому наивный `float(2 ** (excess - 1))` при + excess>=1025 падает с `OverflowError: int too large to convert to float` — + то есть ровно под целевой нагрузкой (1045 неудач по имени за час = 0.3 rps) + защита начинала отдавать 500 мгновенно и без аудита, вместо 401 с задержкой. + 2**16 = 65536с заведомо больше любого разумного потолка, так что зажим + видимого поведения не меняет, а арифметику делает безусловно конечной. + """ + excess = fails_in_window - settings.login_username_fail_threshold + if excess <= 0: + return 0.0 + return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16)) + + +async def _reject_invalid_credentials( + db: Session, username: str, ip: str, user_agent: str | None +) -> HTTPException: + """Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401. + + Один код на все ветки отказа (нет такого имени / неверный пароль / доступ + закрыт / password_hash NULL) — это не борьба с дублированием, а инвариант: + ветки обязаны быть неразличимы снаружи. Разъедься они по телу хендлера — + и достаточно забыть задержку в одной, чтобы «быстрый 401» стал оракулом + существования учётки ровно в том же виде, что и разные сообщения об ошибке. + Поэтому счётчик ведётся по ПРИСЛАННОМУ имени, без проверки, есть ли такое + в реестре: несуществующее имя копит неудачи и тормозит так же, как живое. + (`get_user_by_username` сверяет `username = :username` по text-колонке без + нормализации, так что сырое имя — тот же ключ, что и у поиска: регистром + счётчик не обойти.) + + Возвращает `HTTPException`, а не бросает: `raise await …` не собирается, а + `raise (await …)` читается хуже, чем `raise` над возвращённым значением. + + *db* нужен ровно затем, чтобы ОТДАТЬ соединение перед сном. `get_identity_db` + в дефолтном режиме (`identity_store="tradein"`, он же прод) отдаёт ту же + сессию, что `get_db` — движок с QueuePool на 5+10 соединений. После SELECT в + `get_user_by_username` сессия держит соединение в открытой транзакции, и сон + внутри её области жизни превращал бы каждую спящую попытку в занятое + соединение: ~15 одновременных неудач выбирают пул целиком, и тогда ЛЮБОЙ + эндпоинт ждёт checkout 30с и падает. Отказ в обслуживании против всех сразу — + хуже той блокировки учётки, ради отказа от которой всё это писалось. + """ + fails = _USERNAME_FAIL_LIMITER.record(username) + delay_s = _throttle_delay_s(fails) + + schedule_event( + event_type="login_failed", + username=username, + ip=ip, + user_agent=user_agent, + path="/api/v1/auth/login", + method="POST", + # Состояние глобального счётчика — в аудит: по нему в user_events видно + # именно РАСПРЕДЕЛЁННЫЙ перебор (десятки неудач по одному имени с разных + # ip_address), который иначе выглядит как россыпь одиночных неудач. + payload={"username_fails_in_window": fails, "throttle_delay_s": delay_s}, + ) + + if delay_s > 0: + logger.warning( + "login throttle: username=%r fails=%d delay=%.1fs ip=%s", + username, + fails, + delay_s, + ip, + ) + # Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна: + # вызывающий немедленно делает raise, а повторный close() в самой + # зависимости идемпотентен. + db.close() + # await, не time.sleep: событийный цикл в это время обслуживает всех + # остальных — тормозим перебор, а не сервис. + await asyncio.sleep(delay_s) + + return HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL) + + +@router.post("/login", response_model=LoginResponse) +async def login( + body: LoginRequest, + request: Request, + response: Response, + db: Annotated[Session, Depends(get_identity_db)], +) -> LoginResponse: + ip = _client_ip(request) + user_agent = request.headers.get("user-agent") + rate_key = f"{len(body.username)}:{body.username}:{ip}" + + retry_after = _LOGIN_LIMITER.check(rate_key) + if retry_after is not None: + raise HTTPException( + status_code=429, + detail="слишком много попыток входа, попробуйте позже", + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + + user = get_user_by_username(db, body.username) + hash_to_check = ( + user["password_hash"] + if user is not None and user["password_hash"] is not None + else _DUMMY_PASSWORD_HASH + ) + # ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash + # держит время ответа одинаковым независимо от существования аккаунта. + try: + # key=ip — доля слотов на адрес (#2714): один источник не занимает больше + # половины ёмкости, и вход остаётся открыт тем, кто приходит с других + # адресов. Ключ — ИМЕННО адрес, не имя: имя присылает клиент, и перебор + # менял бы его каждую попытку, получая полную долю на каждое. Границы + # применимости (IP подделывается за вторым прокси, разделяется за NAT, + # ротируется ботнетом) — в docstring `verify_password_bounded`. + password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip) + except PasswordVerifyOverloadedError: + # Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя — + # ждущий держит соединение к БД. Отказ ОДИНАКОВ для любого имени и + # случается ДО сверки, поэтому оракулом существования учётки не служит и + # бюджет неудач по имени не тратит (это не попытка входа: пароль не + # проверялся). Retry-After 1с — порядок времени одной проверки, не окно + # соседнего `_LOGIN_LIMITER`. + logger.warning("login rejected: password verify saturated ip=%s", ip) + raise HTTPException( + status_code=429, + detail="слишком много попыток входа, попробуйте позже", + headers={"Retry-After": "1"}, + ) from None + + # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние + # доступа. Порядок несущий, а не стилистический: см. модульный docstring. + if user is None or not password_ok: + raise await _reject_invalid_credentials(db, body.username, ip, user_agent) + + access_state = user["access_state"] + if access_state is AccessState.TRIAL_EXPIRED: + # Пароль верный, сессия НЕ создаётся. Единственный не-generic ответ: + # аккаунт существует и владелец это уже доказал паролем, так что + # осмысленный текст ничего не раскрывает постороннему. + # В режиме identity_store="tradein" эта ветка недостижима: булев + # is_active даёт только active/disabled (identity_store.to_access_state). + schedule_event( + event_type="login_blocked_expired", + username=user["username"], + ip=ip, + user_agent=user_agent, + path="/api/v1/auth/login", + method="POST", + ) + raise HTTPException( + status_code=403, + detail={"code": _ACCESS_EXPIRED_CODE, "message": _ACCESS_EXPIRED_MESSAGE}, + ) + + if not access_state.can_sign_in: + # disabled (и любое нераспознанное состояние — to_access_state fail-closed) + # → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном + # пароле: заблокированный аккаунт неотличим от несуществующего. + raise await _reject_invalid_credentials(db, body.username, ip, user_agent) + + token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent) + + response.set_cookie( + key=settings.session_cookie_name, + value=token, + max_age=settings.session_ttl_hours * 3600, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + + schedule_event( + event_type="login_success", + username=user["username"], + ip=ip, + user_agent=user_agent, + path="/api/v1/auth/login", + method="POST", + ) + + return LoginResponse(ok=True) + + +@router.post("/logout") +async def logout( + request: Request, + response: Response, + db: Annotated[Session, Depends(get_identity_db)], +) -> dict[str, bool]: + token = request.cookies.get(settings.session_cookie_name) + if token: + revoke_session(db, token) + response.delete_cookie(key=settings.session_cookie_name, path="/") + return {"ok": True} diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index d581f401..e9c25631 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -21,14 +21,27 @@ router = APIRouter() async def lookup( address: Annotated[str, Query(min_length=3, max_length=500)], db: Annotated[Session, Depends(get_db)], + city_hint: Annotated[ + str | None, + Query( + max_length=100, + description=( + "Город, если известен вызывающему (например выбран пользователем " + "на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ " + "подставляет 'Екатеринбург' молча — ответ может помечаться " + "city_ambiguous=true." + ), + ), + ] = None, ) -> GeocodeResult: """Геокодинг адреса → lat/lon. Примеры: /api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург /api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург + /api/v1/geocode/lookup?address=Ленина+1&city_hint=Нижний+Тагил """ - result = await geocode(address, db) + result = await geocode(address, db, city_hint=city_hint) if result is None: raise HTTPException(status_code=404, detail=f"Address not found: {address}") return result @@ -55,6 +68,16 @@ async def suggest_addresses( q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")], limit: Annotated[int, Query(ge=1, le=15)] = 8, db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] + city_hint: Annotated[ + str | None, + Query( + max_length=100, + description=( + "Город, если известен вызывающему (#2576) — без него подсказки " + "БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом." + ), + ), + ] = None, ) -> SuggestResponse: """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, остаётся быстрым fast-path). @@ -66,8 +89,9 @@ async def suggest_addresses( Пример: /api/v1/geocode/suggest?q=Малышева /api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет + /api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил """ - items = await suggest(q, db=db, limit=limit) + items = await suggest(q, db=db, limit=limit, city_hint=city_hint) return SuggestResponse( items=[ SuggestItem( @@ -100,11 +124,11 @@ class ReverseResponse(BaseModel): precision: str = Field( ..., description=( - "Yandex-style: exact/number/street/range/near/locality/other/cadastral. " + "exact/number/street/range/near/locality/other/cadastral. " "Фронт двигает marker только если exact/number/cadastral." ), ) - provider: str = Field(..., description="cadastral | yandex | nominatim") + provider: str = Field(..., description="cadastral | nominatim") @router.get("/reverse", response_model=ReverseResponse) diff --git a/tradein-mvp/backend/app/api/v1/me.py b/tradein-mvp/backend/app/api/v1/me.py index 4b7da093..367b913e 100644 --- a/tradein-mvp/backend/app/api/v1/me.py +++ b/tradein-mvp/backend/app/api/v1/me.py @@ -7,16 +7,31 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст Caddy basic_auth пропускает `X-Authenticated-User: ` через `header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять кому что показывать. + +#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session) +отдаёт scope из реестра людей (role/display_name/org/email) БЕЗ похода в +roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User +путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его +бит-в-бит). + +Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у +`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии +живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы +`get_db`, — поведение прода не меняется. """ from __future__ import annotations import logging -from typing import Annotated +from typing import Annotated, Any -from fastapi import APIRouter, Header, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from sqlalchemy.orm import Session from app.core.auth import UserScope, get_user_scope +from app.core.config import settings +from app.services.auth_session import get_db_role_scope, get_session_user +from app.services.identity_store import get_identity_db logger = logging.getLogger(__name__) @@ -25,13 +40,45 @@ router = APIRouter() @router.get("/me") async def me( + request: Request, + db: Annotated[Session, Depends(get_identity_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, -) -> UserScope: - """Return the current user's RBAC scope (role + allowed/deny paths).""" +) -> UserScope | dict[str, Any]: + """Return the current user's RBAC scope (role + allowed/deny paths). + + Return type is a union (не только `UserScope`) — `UserScope.role` — это + `Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names), + а DB-роли (реестр: tradein_users.role / auth.users.role) — + `"admin"/"manager"/"employee"`. FastAPI + строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы + "employee"/"manager" в ResponseValidationError. Итоговая JSON-форма + ОДИНАКОВАЯ (те же 8 ключей) для обеих веток. + """ + token = request.cookies.get(settings.session_cookie_name) + if token: + try: + session_user = get_session_user(db, token) + except Exception: + logger.exception("me: session lookup failed") + session_user = None + if session_user is not None: + role = session_user["role"] + allowed_paths, deny_paths = get_db_role_scope(role) + return { + "username": session_user["username"], + "role": role, + "allowed_paths": allowed_paths, + "deny_paths": deny_paths, + "brand": None, + "display_name": session_user["display_name"], + "org": session_user["org_name"], + "email": session_user["email"], + } + if not x_authenticated_user: raise HTTPException( status_code=401, - detail="no authenticated user (Caddy basic_auth required)", + detail="no authenticated user (valid session required)", ) try: return get_user_scope(x_authenticated_user) diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py index 0a8c1914..bc8af578 100644 --- a/tradein-mvp/backend/app/api/v1/support.py +++ b/tradein-mvp/backend/app/api/v1/support.py @@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql). `username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию можно безопасно отложить до после успешного sendMessage. Бонус: неудачная отправка больше не создаёт тред. + +Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31) +------------------------------------------------------- +Ровно те же 4 действия, но БЕЗ авторизации — доступны с экрана входа. Причина: +после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был +чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу +войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0 +успешных) и достучаться из продукта не могла ничем. + +Идентичность анонима — opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`), +тред живёт в тех же `web_support_threads` под ключом `anon:`. Двоеточие +делает коллизию с реальным логином структурно невозможной: `tradein_users` +допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic), +двоеточия там быть не может — аноним НИКОГДА не попадёт в чужой тред и не +«станет» существующим юзером. + +Изоляция тредов та же, что у авторизованной ветки, и по той же причине: +thread_id не принимается снаружи ни в каком виде, тред резолвится +ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь — bearer-токен своего треда, поэтому +httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)` +(144 бита) вместо чего-то угадываемого. + +В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>` +(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer — +зеркало топика читают люди и пересылают дальше. """ from __future__ import annotations +import hashlib import logging +import re +import secrets from typing import Annotated, Literal -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import Session from app.core.config import settings from app.core.db import get_db -from app.core.ratelimit import SlidingWindowLimiter +from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.services.tgbot import web_support_storage as storage from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT from app.services.tgbot.client import TelegramApiError, TelegramClient @@ -262,3 +290,204 @@ def mark_support_read( storage.mark_read(db, thread_id=thread_id) db.commit() return StatusOut() + + +# --------------------------------------------------------------------------- +# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля) +# --------------------------------------------------------------------------- + +_ANON_COOKIE_NAME = "tradein_support_anon" +# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно. +_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600 +# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг). +_ANON_THREAD_PREFIX = "anon:" +# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]). +# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр +# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку +# отсутствующей и выдаём новую, а не пытаемся «починить» присланное. +_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z") + +# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у +# авторизованной ветки нет. Два независимых бюджета: +# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются: +# анонимные начинаются с "anon:", что невозможно для username); +# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен → +# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный +# сценарий — «не могу войти, помогите», несколько сообщений подряд. +_ANON_IP_RATE_LIMIT = 10 +_ANON_IP_RATE_WINDOW_S = 600.0 +_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S) + + +def _anon_display_id(token: str) -> str: + """Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля. + + sha256, а не префикс токена: префикс — это часть bearer'а, а зеркало уходит + в Telegram-топик, который читают люди и пересылают дальше. + """ + return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}" + + +def _read_anon_token(request: Request) -> str | None: + """Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей).""" + raw = request.cookies.get(_ANON_COOKIE_NAME) + if raw is None or not _ANON_TOKEN_RE.match(raw): + return None + return raw + + +def _anon_thread_key(token: str) -> str: + return f"{_ANON_THREAD_PREFIX}{token}" + + +def _set_anon_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=_ANON_COOKIE_NAME, + value=token, + max_age=_ANON_COOKIE_MAX_AGE_S, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + + +@router.post("/support/anon/messages", response_model=SupportMessageOut) +async def send_anon_support_message( + payload: SupportMessageInput, + request: Request, + response: Response, + db: Annotated[Session, Depends(get_db)], +) -> SupportMessageOut: + """Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной + ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage. + + Кука выставляется тоже только на успехе — иначе первая же неудачная попытка + (бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред. + """ + if not _bot_configured(): + raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT) + + token = _read_anon_token(request) + is_new_token = token is None + if token is None: + token = secrets.token_urlsafe(18) + thread_key = _anon_thread_key(token) + ip = _client_ip(request) + + # Оба бюджета — non-destructive peek (review L3): неудачная отправка не + # должна стоить посетителю попытки. `.record()` только на успех, ниже. + for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)): + if retry_after is not None: + raise HTTPException( + status_code=429, + detail="Слишком много сообщений. Попробуйте позже.", + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + + display_id = _anon_display_id(token) + client = TelegramClient(settings.telegram_bot_token) + try: + mirrored = await client.send_message( + chat_id=settings.telegram_support_chat_id, + text=_format_anon_mirror_text(display_id, payload.text), + message_thread_id=settings.telegram_support_topic_id or None, + timeout=_INTERACTIVE_SEND_TIMEOUT_S, + max_retries=_INTERACTIVE_SEND_MAX_RETRIES, + ) + except TelegramApiError: + # Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают. + logger.exception( + "web support (anon): не удалось отправить зеркало в топик (%s)", display_id + ) + raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None + + _send_limiter.record(thread_key) + _anon_ip_limiter.record(ip) + + topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None + if topic_message_id is None: + logger.warning( + "web support (anon): Telegram sendMessage не вернул message_id (%s) — " + "ответ оператора на это сообщение не будет смаршрутизирован", + display_id, + ) + + thread_id = storage.get_or_create_thread(db, thread_key) + row = storage.record_inbound( + db, + thread_id=thread_id, + text_body=payload.text, + topic_message_id=topic_message_id, + support_chat_id=settings.telegram_support_chat_id, + ) + db.commit() + + if is_new_token: + _set_anon_cookie(response, token) + logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id) + return SupportMessageOut(**row) + + +def _format_anon_mirror_text(display_id: str, message_text: str) -> str: + """Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя. + + Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому + можно посмотреть историю, и самая вероятная причина написать — как раз + невозможность войти (инцидент 2026-07-31). + """ + return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}" + + +@router.get("/support/anon/messages", response_model=list[SupportMessageOut]) +def list_anon_support_messages( + request: Request, + db: Annotated[Session, Depends(get_db)], + since: Annotated[int, Query(ge=0)] = 0, +) -> list[SupportMessageOut]: + """Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет + поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой. + + Sync `def` (review M3) — см. `list_support_messages`. + """ + token = _read_anon_token(request) + if token is None: + return [] + thread_id = storage.find_thread_id(db, _anon_thread_key(token)) + if thread_id is None: + return [] + rows = storage.list_messages( + db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT + ) + return [SupportMessageOut(**r) for r in rows] + + +@router.get("/support/anon/unread", response_model=UnreadOut) +def get_anon_support_unread( + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> UnreadOut: + """Sync `def` (review M3) — см. `list_support_messages`.""" + token = _read_anon_token(request) + if token is None: + return UnreadOut(unread=0) + thread_id = storage.find_thread_id(db, _anon_thread_key(token)) + if thread_id is None: + return UnreadOut(unread=0) + return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id)) + + +@router.post("/support/anon/read", response_model=StatusOut) +def mark_anon_support_read( + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> StatusOut: + """Sync `def` (review M3) — см. `list_support_messages`.""" + token = _read_anon_token(request) + if token is None: + return StatusOut() + thread_id = storage.find_thread_id(db, _anon_thread_key(token)) + if thread_id is not None: + storage.mark_read(db, thread_id=thread_id) + db.commit() + return StatusOut() diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py new file mode 100644 index 00000000..d42bd250 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -0,0 +1,818 @@ +"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549). + +Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это +`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS` +уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`) +для `rbac_guard` (см. `app.core.rbac`) — этот роутер добавляет ВТОРОЙ, +более узкий барьер именно на identity: + + - `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie + (`app.services.auth_session.get_session_user`). Legacy + `X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается + здесь — team-API новый, не участвует в переходном dual-mode auth. Без + валидной cookie — 401, даже если `rbac_guard` пропустил запрос по + legacy-заголовку (напр. admin через roles.yaml). + - Роль должна быть `admin` или `manager` — иначе 403. + +Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих +employee (`<реестр>.manager_id = actor.user_id`). Чужой/несуществующий +employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование +чужого сотрудника перед manager'ом. См. `_authorize_employee`. + +ДВЕ СЕССИИ БД, и это не дублирование: + - `identity_db` (`Depends(get_identity_db)`) — реестр людей: строка сотрудника + и его сессии. При `IDENTITY_STORE=auth` это ДРУГАЯ БД (`auth`). + - `db` (`Depends(get_db)`) — продуктовые таблицы «Меры», которые в общий + реестр не переезжают: `account_quota_overrides`, `account_estimate_usage`, + `user_events`, `trade_in_estimates`. +В дефолтном режиме (`IDENTITY_STORE=tradein`) это ОДИН И ТОТ ЖЕ объект `Session` +(см. `identity_store.get_identity_db`), поэтому всё по-прежнему коммитится одной +транзакцией — прод не меняется. В режиме `auth` транзакции физически две: +порядок коммитов выбран так, чтобы при сбое второго коммита оставалось менее +вредное состояние (см. комментарии у `db.commit()`), а `db is not identity_db` — +рантайм-признак «БД разные». + +Гранты роли `auth_app` (data/sql/auth/004, Часть 4) этот роутер соблюдает без +обходов: он ПИШЕТ только `password_hash, display_name, org_name, email, +access_state, updated_at` (ровно column-level GRANT UPDATE), вставляет строку +целиком (табличный GRANT INSERT) и НИКОГДА не пишет `role`/`manager_id` +UPDATE'ом и не делает DELETE по `users`. + +DELETE по `sessions` реестра — штатный и грантом предусмотрен (data/sql/auth/002, +GRANT DELETE на sessions): блокировка и смена пароля обязаны рвать живые сессии +немедленно, это `revoke_user_sessions` из `app.services.auth_session`, вызываемый +из `update_employee`. То есть периметр DELETE у этого роутера — ровно `sessions` +и ничего больше; грант DELETE на sessions не лишний. + +Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`): + - actor manager → только `role='employee'` И только своих (как было). + - actor admin → `role IN ('employee','manager')`. + +Почему admin'у отдали и менеджеров (инцидент 2026-07-31): после cutover'а на +DB-auth (#2558) аккаунты `kopylov`/`praktika` сидят с `role='manager'`, а этот +роутер жёстко фильтровал `role='employee'` — сбросить менеджеру пароль или +заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Роль manager вводилась +как «владелец своей организации», а не как «неприкасаемый аккаунт». + +`role='admin'` НЕ входит ни в один набор, и это несущий инвариант, а не +экономия: он один держит невозможность self-lockout'а. Актёр этого роутера — +всегда admin или manager (`current_team_actor`); manager до admin-строки не +дотянется по своей ветке фильтра, а admin не дотянется до admin-строки вообще — +в том числе до собственной. Поэтому ни один путь ниже (block, смена пароля + +`revoke_user_sessions`) не может вырубить самого действующего админа или +разжаловать другого. Раздача/отзыв роли admin остаётся операцией уровня +миграции/psql — сознательно вне API. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Annotated, Any +from urllib.parse import urlparse + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import text +from sqlalchemy.engine import RowMapping +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.config import settings +from app.core.db import get_db +from app.core.password import hash_password +from app.schemas.team import ( + EmployeeCreateRequest, + EmployeeHistoryEntry, + EmployeeOut, + EmployeeUpdateRequest, + QuotaStatusOut, +) +from app.services import account_quota +from app.services.auth_session import get_session_user, revoke_user_sessions +from app.services.identity_store import ( + AccessState, + IdentitySchema, + access_state_param, + get_identity_db, + identity_schema, + to_access_state, +) +from app.services.user_events import schedule_event + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@dataclass +class TeamActor: + """Резолвленный из session-cookie актёр team-API — admin или manager.""" + + user_id: int + username: str + role: str # "admin" | "manager" + + +async def current_team_actor( + request: Request, + identity_db: Annotated[Session, Depends(get_identity_db)], +) -> TeamActor: + """Dependency: session-only identity, роль admin|manager, иначе 401/403. + + Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring. + Сессия резолвится в БД РЕЕСТРА (см. про две сессии в модульном docstring). + """ + token = request.cookies.get(settings.session_cookie_name) + if not token: + raise HTTPException(status_code=401, detail="valid session required") + + try: + session_user = get_session_user(identity_db, token) + except Exception: + logger.exception("team: session lookup failed") + raise HTTPException(status_code=401, detail="valid session required") from None + + if session_user is None: + raise HTTPException(status_code=401, detail="valid session required") + + role = session_user["role"] + if role not in ("admin", "manager"): + raise HTTPException(status_code=403, detail="admin or manager role required") + + return TeamActor( + user_id=session_user["user_id"], + username=session_user["username"], + role=role, + ) + + +def _origin_host_allowed(candidate: str) -> bool: + """True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`. + + `cors_origins` уже является источником правды для «какие origin'ы это наш + фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) — переиспользуем + его вместо нового хардкода.""" + try: + parsed = urlparse(candidate) + except ValueError: + return False + if not parsed.scheme or not parsed.netloc: + return False + origin = f"{parsed.scheme}://{parsed.netloc}" + return origin in settings.cors_origins + + +def _require_same_origin(request: Request) -> None: + """CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов + (POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из + `settings.cors_origins`, иначе 403. + + Оба заголовка отсутствуют → ПРОПУСКАЕМ (не 403). Причина: это единственный + надёжный сигнал non-browser клиента в этом стеке — curl-смоуки внутри + контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ + контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих + заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin + на unsafe-методах (POST/PATCH) — так что "оба отсутствуют" практически + невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie + уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) — это первый рубеж + против CSRF, Origin-check — второй. + """ + candidate = request.headers.get("origin") or request.headers.get("referer") + if candidate is None: + return + if not _origin_host_allowed(candidate): + logger.warning( + "team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path + ) + raise HTTPException(status_code=403, detail="origin not allowed") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +# Имена таблицы и колонки состояния доступа приходят из `identity_schema()` — +# фиксированный словарь в `app.services.identity_store`, единственный источник +# этих имён (в SQL-строку не попадает ничего пришедшего снаружи; значения +# по-прежнему биндятся параметрами). +# +# `AS access_state` в КАЖДОМ SELECT'е — не косметика: колонка называется +# по-разному в двух схемах, и без алиаса вызывающий код читал бы то `is_active`, +# то `access_state`, то есть завёл бы то самое второе представление состояния, +# которого быть не должно. Дальше значение всегда идёт через `to_access_state()`. +def _employee_columns(schema: IdentitySchema) -> str: + return ( + "id, username, role, display_name, org_name, email, " + f"{schema.access_state_column} AS access_state, manager_id, created_at" + ) + + +# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что +# у `_list_employees_sql` ниже: значения и так биндятся параметрами, но +# статические ветки не провоцируют будущие правки в сторону конкатенации SQL). +# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring. +def _fetch_employee_sql(actor_role: str) -> TextClause: + schema = identity_schema() + cols = _employee_columns(schema) + if actor_role == "admin": + return text( + f"SELECT {cols} FROM {schema.users_table} " + "WHERE id = :id AND role IN ('employee', 'manager')" + ) + return text(f"SELECT {cols} FROM {schema.users_table} WHERE id = :id AND role = 'employee'") + + +def _fetch_employee_row( + identity_db: Session, employee_id: int, actor: TeamActor +) -> RowMapping | None: + """Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404). + + Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже: + для manager'а строка менеджера/админа не должна даже доехать до + вызывающего кода. `None` для обоих случаев («нет такого id» и «этот id + тебе не по зубам») — тот же принцип, что и 404-вместо-403 в + `_authorize_employee`: не палим существование чужой строки. + """ + sql = _fetch_employee_sql(actor.role) + return identity_db.execute(sql, {"id": employee_id}).mappings().fetchone() + + +def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: + """404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у. + + Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`. + 404 вместо 403 — не палим существование чужого employee_id. + + Для admin'а доп. проверки нет: набор строк, до которых он вообще может + дотянуться, уже ограничен ролью в `_fetch_employee_row` (employee|manager, + без admin). У менеджерских строк `manager_id` штатно NULL — сравнивать его + с чем-либо здесь нечего. + """ + if row is None: + raise HTTPException(status_code=404, detail="employee not found") + if actor.role == "manager" and row["manager_id"] != actor.user_id: + raise HTTPException(status_code=404, detail="employee not found") + return row + + +def _upsert_quota_override( + db: Session, username: str, monthly_limit: int, actor_username: str +) -> None: + """Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу + numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может + молча не применяться — прежний unlimited-грант выигрывал бы у нового limit). + `note` — НЕ затирается, если уже задан (`COALESCE`): не перезаписываем + человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча + сгенерированной строкой; note проставляется только при первом upsert записи. + """ + db.execute( + text( + """ + INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note) + VALUES (:username, CAST(:monthly_limit AS integer), false, :note) + ON CONFLICT (username) DO UPDATE SET + monthly_limit = EXCLUDED.monthly_limit, + unlimited = false, + note = COALESCE(account_quota_overrides.note, EXCLUDED.note), + updated_at = now() + """ + ), + { + "username": username, + "monthly_limit": monthly_limit, + "note": f"team-api: set by {actor_username}", + }, + ) + + +def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]: + """Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса + вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563). + + Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status` + (follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА + читал `account_quota_overrides.unlimited`, а `is_unlimited` — ТОЛЬКО для + username, присутствующего в roles.yaml): + - username НЕ в roles.yaml (`get_role` → KeyError) → unlimited=False ВСЕГДА, + `account_quota_overrides.unlimited` даже не проверяется (roles.yaml — + источник правды "кто вообще может быть unlimited", override — "у кого + именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only + сотрудников team-API (`_upsert_quota_override` всегда пишет + `unlimited=false`), но станет достижимым при ручном UPDATE + `account_quota_overrides` или расширении roles.yaml — расхождение с + реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`) + было бы честной ложью в списке: "без лимита", который движок всё равно + считает. + - username в roles.yaml и role == admin → unlimited=True (без похода в БД). + - username в roles.yaml, role != admin → unlimited = override.unlimited. + limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по + roles.yaml — так же ведёт себя `account_quota.user_limit`), иначе глобальный + `account_quota.MONTHLY_LIMIT`. + """ + if not usernames: + return {} + + overrides = ( + db.execute( + text( + """ + SELECT username, monthly_limit, unlimited + FROM account_quota_overrides + WHERE username = ANY(CAST(:usernames AS text[])) + """ + ), + {"usernames": usernames}, + ) + .mappings() + .all() + ) + override_by_username = {r["username"]: r for r in overrides} + + period = account_quota.current_period() + usage_rows = ( + db.execute( + text( + """ + SELECT username, used + FROM account_estimate_usage + WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period + """ + ), + {"usernames": usernames, "period": period}, + ) + .mappings() + .all() + ) + used_by_username = {r["username"]: r["used"] for r in usage_rows} + + 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 + if role == "admin": + unlimited = True + elif role is not None: + unlimited = bool(override is not None and override["unlimited"]) + else: + # username не в roles.yaml — is_unlimited() короткое замыкание на + # False, override НЕ проверяется (см. докстринг выше). + unlimited = False + limit = ( + int(override["monthly_limit"]) + if override is not None and override["monthly_limit"] is not None + else account_quota.MONTHLY_LIMIT + ) + used = used_by_username.get(username, 0) + if unlimited: + result[username] = { + "limit": limit, + "used": used, + "remaining": limit, + "unlimited": True, + } + else: + remaining = max(0, limit - max(0, used)) + result[username] = { + "limit": limit, + "used": used, + "remaining": remaining, + "unlimited": False, + } + return result + + +def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut: + """Строка реестра → ответ API. + + `is_active` в контракте API остаётся булевым (форма ответа не меняется — + фронт «Команды» не трогаем этим PR), и считается он ровно как «пустят ли + входить»: `trial_expired` показывается как заблокированный. Отдельное + отображение пробного периода в «Команде» — вопрос UI-PR'а, не этого. + """ + return EmployeeOut( + id=row["id"], + username=row["username"], + role=row["role"], + display_name=row["display_name"], + org_name=row["org_name"], + email=row["email"], + is_active=to_access_state(row["access_state"]).can_sign_in, + manager_id=row["manager_id"], + created_at=row["created_at"], + quota=QuotaStatusOut(**quota), + ) + + +# --------------------------------------------------------------------------- +# POST /employees +# --------------------------------------------------------------------------- + + +@router.post("/employees", response_model=EmployeeOut, status_code=201) +async def create_employee( + body: EmployeeCreateRequest, + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], + _origin_check: Annotated[None, Depends(_require_same_origin)], +) -> EmployeeOut: + """Создать сотрудника. Роль всегда `employee`. + + manager_id: для actor.role == manager — принудительно свой id (любое + значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для + actor.role == admin — опционально из тела, валидируется что указанный id + существует и role='manager' (иначе 422). + + `identity_db` — реестр (строка сотрудника), `db` — продуктовая квота; + в дефолтном режиме это одна и та же сессия и одна транзакция. + """ + schema = identity_schema() + existing = identity_db.execute( + text(f"SELECT id FROM {schema.users_table} WHERE username = :u"), + {"u": body.username}, + ).fetchone() + if existing is not None: + raise HTTPException(status_code=409, detail="username already exists") + + try: + password_hash = hash_password(body.password) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from None + + manager_id: int | None + if actor.role == "manager": + # Инвариант org-изоляции: manager не может создать сотрудника под + # чужим manager_id — любое значение из тела игнорируется молча. + manager_id = actor.user_id + else: + manager_id = body.manager_id + if manager_id is not None: + mgr = identity_db.execute( + text(f"SELECT id FROM {schema.users_table} WHERE id = :id AND role = 'manager'"), + {"id": manager_id}, + ).fetchone() + if mgr is None: + raise HTTPException( + status_code=422, + detail="manager_id does not reference an existing manager", + ) + + try: + row = ( + identity_db.execute( + text( + f""" + INSERT INTO {schema.users_table} + (username, password_hash, role, manager_id, display_name, org_name, + email, {schema.access_state_column}) + VALUES + (:username, :password_hash, 'employee', :manager_id, :display_name, + :org_name, :email, :access_state) + RETURNING {_employee_columns(schema)} + """ + ), + { + "username": body.username, + "password_hash": password_hash, + "manager_id": manager_id, + "display_name": body.display_name, + "org_name": body.org_name, + "email": body.email, + # Новый сотрудник заводится с открытым доступом — как и + # раньше (`is_active = true` литералом). Литерала здесь + # больше нет: тип колонки разный, знает о нём identity_store. + "access_state": access_state_param(AccessState.ACTIVE), + }, + ) + .mappings() + .fetchone() + ) + except IntegrityError: + # TOCTOU: два конкурентных POST с одинаковым username между pre-check + # выше и этим INSERT — UNIQUE-констрейнт на username в реестре ловит. + identity_db.rollback() + raise HTTPException(status_code=409, detail="username already exists") from None + + assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку + + if body.monthly_limit is not None: + _upsert_quota_override(db, body.username, body.monthly_limit, actor.username) + + # Реестр коммитится ПЕРВЫМ. В дефолтном режиме это один коммит на одну + # транзакцию (identity_db is db) — ровно как было. В режиме `auth` БД две, + # и порядок выбран по цене сбоя: не доехавшая квота — это сотрудник с + # глобальным лимитом (чинится повторным PATCH), тогда как не доехавшая + # строка сотрудника при уже сохранённой квоте — висящий override на + # несуществующего человека. + identity_db.commit() + if db is not identity_db: + db.commit() + + schedule_event( + event_type="employee_created", + username=actor.username, + payload={ + "employee_id": row["id"], + "employee_username": row["username"], + "manager_id": manager_id, + }, + ) + + quota = account_quota.get_status(db, body.username) + return _employee_out(row, quota) + + +# --------------------------------------------------------------------------- +# PATCH /employees/{id} +# --------------------------------------------------------------------------- + + +@router.patch("/employees/{employee_id}", response_model=EmployeeOut) +async def update_employee( + employee_id: int, + body: EmployeeUpdateRequest, + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], + _origin_check: Annotated[None, Depends(_require_same_origin)], +) -> EmployeeOut: + """Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль. + + manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404. + При is_active=False ИЛИ смене пароля (new_password) — обязательно revoke всех + сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют + до истечения TTL текущей сессии сотрудника — хуже того, sliding-refresh + (`app.services.auth_session.get_session_user`) продлевает `expires_at` на + КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно + долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим + use-case — см. его докстринг. + + `is_active` в теле остаётся булевым (контракт API не меняется): true → + `active`, false → `disabled`. Перевести аккаунт В `trial_expired` этим + роутом нельзя — это состояние проставляется миграцией/владельцем, а + выразить его булевым полем нечем; is_active=true на таком аккаунте открывает + доступ (снимает пробное ограничение), is_active=false закрывает жёстко. + """ + row = _fetch_employee_row(identity_db, employee_id, actor) + row = _authorize_employee(actor, row) + + new_password_hash: str | None = None + if body.new_password is not None: + try: + new_password_hash = hash_password(body.new_password) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from None + + schema = identity_schema() + # Пишутся РОВНО те колонки, на которые у auth_app есть column-level GRANT + # UPDATE (data/sql/auth/004, Часть 4): password_hash, display_name, org_name, + # email, access_state, updated_at. role и manager_id этим роутом не + # обновляются — не «пока не понадобилось», а сознательно: право на их запись + # роли приложения не выдано, и добавлять его в обход миграции нельзя. + # + # CAST обязателен из-за NULL-параметра (поле не пришло в PATCH → COALESCE + # оставляет текущее значение): у нетипизированного NULL Postgres не может + # вывести тип. Имя SQL-типа — из фиксированного словаря identity_store. + identity_db.execute( + text( + f""" + UPDATE {schema.users_table} + SET display_name = COALESCE(:display_name, display_name), + org_name = COALESCE(:org_name, org_name), + email = COALESCE(:email, email), + {schema.access_state_column} = COALESCE( + CAST(:access_state AS {schema.access_state_sql_type}), + {schema.access_state_column} + ), + password_hash = COALESCE(:password_hash, password_hash), + updated_at = now() + WHERE id = :id + """ + ), + { + "display_name": body.display_name, + "org_name": body.org_name, + "email": body.email, + "access_state": ( + None + if body.is_active is None + else access_state_param( + AccessState.ACTIVE if body.is_active else AccessState.DISABLED + ) + ), + "password_hash": new_password_hash, + "id": employee_id, + }, + ) + + if body.monthly_limit is not None: + _upsert_quota_override(db, row["username"], body.monthly_limit, actor.username) + + if body.is_active is False or body.new_password is not None: + # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions + # коммитит сам (см. app.services.auth_session), это флашит и наш + # предшествующий UPDATE (а в дефолтном режиме, где сессия одна, — и + # quota-upsert). Сессии живут в БД реестра, вместе с пользователем, + # поэтому рвём их через `identity_db`: с чужой сессией здесь блокировка + # и смена пароля перестали бы действовать немедленно. Self-lockout + # невозможен: _fetch_employee_row не отдаёт строки с role='admin' + # НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor + # (admin|manager) никогда не может патчить сам себя через этот роут. + revoke_user_sessions(identity_db, employee_id) + + # Порядок и смысл — как в create_employee: реестр первым, продуктовая БД + # отдельным коммитом только если она физически другая. + identity_db.commit() + if db is not identity_db: + db.commit() + + changed_profile_fields = [ + f + for f, v in ( + ("display_name", body.display_name), + ("org_name", body.org_name), + ("email", body.email), + ) + if v is not None + ] + if changed_profile_fields: + schedule_event( + event_type="employee_updated", + username=actor.username, + payload={ + "employee_id": employee_id, + "employee_username": row["username"], + "fields": changed_profile_fields, + }, + ) + if body.new_password is not None: + schedule_event( + event_type="employee_password_reset", + username=actor.username, + payload={"employee_id": employee_id, "employee_username": row["username"]}, + ) + if body.is_active is not None: + schedule_event( + event_type="employee_blocked" if body.is_active is False else "employee_unblocked", + username=actor.username, + payload={"employee_id": employee_id, "employee_username": row["username"]}, + ) + if body.monthly_limit is not None: + schedule_event( + event_type="quota_changed", + username=actor.username, + payload={ + "employee_id": employee_id, + "employee_username": row["username"], + "monthly_limit": body.monthly_limit, + }, + ) + + updated_row = _fetch_employee_row(identity_db, employee_id, actor) + assert updated_row is not None # только что успешно обновили эту же строку + quota = account_quota.get_status(db, updated_row["username"]) + return _employee_out(updated_row, quota) + + +# --------------------------------------------------------------------------- +# GET /employees +# --------------------------------------------------------------------------- + + +# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/ +# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны, +# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL). +# +# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up +# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed +# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк. +# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для +# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при +# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный +# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и +# намерение DESC-сортировки по времени). +# +# Admin-ветка (`by_manager=False`): сюда попадают И менеджеры (см. модульный +# docstring — иначе admin не видит в UI строку, которой должен уметь сбросить +# пароль). `role='admin'` по-прежнему невидим и неуправляем. Сортировка по +# (created_at, id) общая для обеих веток — намеренно: seed (#2557) вставил всех +# одной транзакцией, так что группировка «сначала менеджеры» дала бы ложное +# ощущение иерархии там, где её в данных нет; роль показывается колонкой +# (`EmployeeOut.role`). +def _list_employees_sql(*, by_manager: bool) -> TextClause: + schema = identity_schema() + cols = _employee_columns(schema) + tail = "ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset" + if by_manager: + return text( + f"SELECT {cols} FROM {schema.users_table} " + f"WHERE role = 'employee' AND manager_id = :manager_id {tail}" + ) + return text( + f"SELECT {cols} FROM {schema.users_table} WHERE role IN ('employee', 'manager') {tail}" + ) + + +@router.get("/employees", response_model=list[EmployeeOut]) +async def list_employees( + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], + manager_id: Annotated[int | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, +) -> list[EmployeeOut]: + """Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=. + + Сотрудники читаются из реестра (`identity_db`), квоты — из продуктовой БД + (`db`): `account_quota_overrides`/`account_estimate_usage` в общий реестр не + переезжают. Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), + не N+1 (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников). + """ + if actor.role == "manager": + rows = ( + identity_db.execute( + _list_employees_sql(by_manager=True), + {"manager_id": actor.user_id, "limit": limit, "offset": offset}, + ) + .mappings() + .all() + ) + elif manager_id is not None: + rows = ( + identity_db.execute( + _list_employees_sql(by_manager=True), + {"manager_id": manager_id, "limit": limit, "offset": offset}, + ) + .mappings() + .all() + ) + else: + rows = ( + identity_db.execute( + _list_employees_sql(by_manager=False), {"limit": limit, "offset": offset} + ) + .mappings() + .all() + ) + + quota_by_username = _batch_quota_status(db, [row["username"] for row in rows]) + return [_employee_out(row, quota_by_username[row["username"]]) for row in rows] + + +# --------------------------------------------------------------------------- +# GET /employees/{id}/history +# --------------------------------------------------------------------------- + + +@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry]) +async def employee_history( + employee_id: int, + actor: Annotated[TeamActor, Depends(current_team_actor)], + db: Annotated[Session, Depends(get_db)], + identity_db: Annotated[Session, Depends(get_identity_db)], + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, +) -> list[EmployeeHistoryEntry]: + """История оценок сотрудника (адрес/дата/результат) — из `user_events`, + LEFT JOIN `trade_in_estimates` за фактическим результатом. + + Та же org-проверка что и в PATCH: чужой employee_id → 404. Проверка идёт по + реестру (`identity_db`), сама история — продуктовые таблицы (`db`). + """ + row = _fetch_employee_row(identity_db, employee_id, actor) + row = _authorize_employee(actor, row) + + rows = ( + db.execute( + text( + """ + SELECT + CAST(ue.estimate_id AS text) AS estimate_id, + ue.payload ->> 'address' AS address, + ue.payload ->> 'area_m2' AS area_m2, + ue.payload ->> 'rooms' AS rooms, + te.median_price, + te.confidence, + te.n_analogs, + ue.created_at + FROM user_events ue + LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id + WHERE ue.username = :username AND ue.event_type = 'estimate_request' + ORDER BY ue.created_at DESC + LIMIT :limit OFFSET :offset + """ + ), + {"username": row["username"], "limit": limit, "offset": offset}, + ) + .mappings() + .all() + ) + + return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows] diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 4493c542..5335901b 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -63,7 +63,7 @@ def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | if not x_authenticated_user: raise HTTPException( status_code=401, - detail="no authenticated user (Caddy basic_auth required)", + detail="no authenticated user (valid session required)", ) from app.core.auth import get_role @@ -702,7 +702,7 @@ def estimate_history( if not x_authenticated_user: raise HTTPException( status_code=401, - detail="no authenticated user (Caddy basic_auth required)", + detail="no authenticated user (valid session required)", ) from app.core.auth import get_role @@ -764,7 +764,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: trade_in_estimates с непустым address; NULL при отсутствии адресов. NB: это честный best-effort по persisted оценкам, а не hit-rate реального кэша (отдельного счётчика попаданий не ведём). + + #2660: listings_active сам по себе врал — «активно» на проде не означает + «живо» (деактиватор протухших покрывает не все источники). Рядом отдаём + listings_active_stale — сколько из них не виделись listings_stale_days + (= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при + 20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем. """ + from app.services.estimator import LISTINGS_FRESH_DAYS + row = ( db.execute( text( @@ -774,6 +782,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW()) AS geocode_cache_fresh, (SELECT count(*) FROM listings WHERE is_active) AS listings_active, + (SELECT count(*) FROM listings + WHERE is_active + AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval) + AS listings_active_stale, (SELECT max(scraped_at) FROM listings) AS listings_last_scraped, (SELECT count(*) FROM deals) AS deals, (SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings, @@ -790,12 +802,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: WHERE address IS NOT NULL AND address <> '' ) t) AS repeat_address_pct """ - ) + ), + {"fresh_days": LISTINGS_FRESH_DAYS}, ) .mappings() .fetchone() ) - return dict(row) if row else {} + # Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней», + # а не заводил второе определение свежести у себя. + return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {} # ── Stage 4a: house info + IMV benchmark для UI ─────────────────────────────── @@ -1820,6 +1835,108 @@ def get_street_deals( # ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ────────────── +# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по +# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице +# сделка и объявление могут стоять в разных домах и разных ценовых классах, и +# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами. +# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем +# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана +# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36% +# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём +# показывать число, которому нельзя верить. +# +# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ +# пользовательских запросах из trade_in_estimates — тот же address/area/rooms, +# что уходил в виджет; 128 из них дали хотя бы одну пару): +# +# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной +# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки +# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0, +# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п. +# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц). +# Совпадает с уже принятым в продукте порогом малой выборки +# settings.sell_time_sensitivity_min_n_lots = 10. +# +# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа: +# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН: +# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5, +# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет +# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один +# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132 +# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого +# рыночно объяснимого плюса. +# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо: +# у большого отрицательного торга есть механизм (занижение цены в ДКП), в +# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»: +# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза +# глубже. Режет 6 групп из 128 (−87 … −64). +# +# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%) +# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся: +# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно +# строка «медианный торг», и вместо неё уходит median_discount_explanation. +# +# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ, +# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но +# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп +# медиана — 18 сделок на одно различное объявление. До этого порога из 64 +# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс — +# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на +# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на +# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира +# отличалась от типичной сделки, а не о торге на улице. +# +# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс +# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп, +# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум +# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с +# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще: +# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0. +# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по +# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5), +# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому +# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о +# разбросе которой можно спрашивать, и показывать число = фабриковать точность. +# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп) +# говорят, что он должен быть выше — но ценой почти всей витрины: +# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4; +# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0); +# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5. +# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё +# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и +# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» — +# решение владельца, не гейта; здесь снимается ровно то, что не является +# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя: +# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность). +# +# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой +# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм +# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую +# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64 +# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком +# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один +# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и +# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия +# работала против пользователя: абсурдный плюс сам себя опровергает («продали +# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит +# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за +# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение +# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9. +# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался. +# +# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится): +# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721). +# Гейт по различным объявлениям честный промежуточный шаг, а не решение: +# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё +# сравнивают сделку в одном доме с объявлением в другом. +# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число +# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%, +# +73% против той же одной цены предложения. Отдельная задача. +SALES_VS_LISTINGS_MIN_PAIRS = 10 +SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2 +SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0 +SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0 + @router.get("/sales-vs-listings", response_model=SalesVsListingsResponse) def get_sales_vs_listings( @@ -1845,7 +1962,7 @@ def get_sales_vs_listings( Per-street view: Росреестр open dataset агрегирует адреса до улицы. """ - from app.services.estimator import _percentile, extract_street_name + from app.services.estimator import _percentile, _resolve_target_city, extract_street_name def _empty(reason_street: str | None = None) -> SalesVsListingsResponse: return SalesVsListingsResponse( @@ -1866,6 +1983,15 @@ def get_sales_vs_listings( logger.warning("sales-vs-listings: cannot extract street from %r", address) return _empty() + # #2583 H4 city-scope (зеркало /street-deals #C1, trade_in.py:1717): без него + # street_pattern матчит одноимённые улицы ЛЮБОГО города обл.66 на ОБЕИХ сторонах + # JOIN (deals.address / listings.address хранят "<Город>, <Улица>") — прод-аудит + # показал 49% явно чужого города + 50% NULL-city listings для проверенных стритов, + # медианный discount_pct уезжал в -60%+ на смеси рынков. target_city резолвится тем + # же словарём (~30 городов обл.66), что и street-deals; None (адрес вне словаря, + # известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205). + target_city = _resolve_target_city(address) + rows = ( db.execute( text( @@ -1882,7 +2008,8 @@ def get_sales_vs_listings( CAST(:rooms AS integer), CAST(:window_days AS integer), CAST(:area_tolerance AS numeric), - CAST(:period_months AS integer) + CAST(:period_months AS integer), + CAST(:target_city AS text) ) """ ), @@ -1893,6 +2020,7 @@ def get_sales_vs_listings( "window_days": window_days, "area_tolerance": area_tolerance, "period_months": period_months, + "target_city": target_city, }, ) .mappings() @@ -1948,12 +2076,69 @@ def get_sales_vs_listings( discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None + # #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts) + # считает сделки, а не наблюдения рынка — одно объявление попадает в пару + # к десяткам сделок улицы (см. шапку секции). + n_distinct_listings = len( + {p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None} + ) + + # #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо + # отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь + # прочитает как поломку, а не как честность. + median_discount_explanation: str | None = None + if median_discount is not None: + if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS: + # Формулировка — ФАКТ про выборку, а не обещание надёжности выше + # порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше — + # пары псевдореплики), обещать «от 10 надёжно» мы не вправе. + median_discount_explanation = ( + f"Медианный торг не показываем: пар «сделка ↔ объявление» всего " + f"{len(discounts)} — на такой выборке медиана гуляет на десятки " + f"процентных пунктов." + ) + elif n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS: + # Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1» + # грамматично при любом значении, «на 1 различных объявлений» — нет. + median_discount_explanation = ( + f"Медианный торг не показываем: сделок {len(discounts)}, а разных " + f"объявлений для сравнения всего {n_distinct_listings} — такой процент " + f"говорит о цене одной конкретной квартиры, а не о торге на улице." + ) + elif not ( + SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT + <= median_discount + <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT + ): + # Типографский минус (U+2212) — как в fmtDiscount на фронте. + shown = f"{median_discount:+.1f}".replace("-", "−") + # Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно + # следующим блоком это говорит street_only-дисклеймер (карточка) / + # хвост note (v2-mappers). Проверено скриншотом — две формулировки + # подряд читались как стена текста. + median_discount_explanation = ( + f"Медианный торг не показываем: расчёт дал неправдоподобное значение " + f"({shown}%) — такого торга на рынке не бывает." + ) + if median_discount_explanation is not None: + logger.info( + "sales-vs-listings: median_discount gated street=%r rooms=%d " + "n_pairs=%d distinct_listings=%d value=%+.2f%%", + street_name, + rooms, + len(discounts), + n_distinct_listings, + median_discount, + ) + median_discount = None logger.info( - "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s", + "sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d " + "linkage=%.1f%% median_disc=%s", street_name, total_deals, deals_with_listings, + n_distinct_listings, linkage_rate_pct, f"{median_discount:+.2f}%" if median_discount is not None else "n/a", ) @@ -1967,6 +2152,7 @@ def get_sales_vs_listings( deals_with_listings=deals_with_listings, linkage_rate_pct=linkage_rate_pct, median_discount_pct=median_discount, + median_discount_explanation=median_discount_explanation, # street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) → # даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им. data_quality="street_only" if total_deals > 0 else "no_data", diff --git a/tradein-mvp/backend/app/core/auth_db.py b/tradein-mvp/backend/app/core/auth_db.py new file mode 100644 index 00000000..0b87bc54 --- /dev/null +++ b/tradein-mvp/backend/app/core/auth_db.py @@ -0,0 +1,162 @@ +"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»). + +Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: +`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в +теле модуля). Сделай мы так же для БД `auth` — приложение начало бы падать на +старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас +ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. +Здесь engine создаётся ЛЕНИВО, при первом реальном обращении. + +Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас): + + * `settings.identity_store == "tradein"` (дефолт) — в этот модуль не заходит + никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая + конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни + одно соединение с БД `auth` не открывается. + * `settings.identity_store == "auth"` + не сконфигурированный реестр — первое + же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. + Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат: + молчаливая деградация auth-пути означала бы «пользователь не найден» вместо + «конфигурация сломана», то есть массовый отказ входа под видом неверных + паролей — либо, в обратную сторону, анонимный доступ. + +Сам DSN этот модуль НЕ выбирает и НЕ склеивает — берёт готовый у +`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из +`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто). + +⚠️ В DSN — пароль роли `auth_app`. Он не логируется и не попадает в текст +исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже — константы, а ошибку +разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и +заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в +traceback. Добавляешь сюда `logger`/`raise ... {dsn}` — не добавляй. + +`create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что +даже после первого обращения реальный коннект открывается только на первом +запросе — но ошибку конфигурации мы обязаны отдать раньше, чем это станет +похоже на сетевую проблему. +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import Engine, create_engine +from sqlalchemy.exc import ArgumentError +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + + +class AuthDatabaseNotConfiguredError(RuntimeError): + """`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался.""" + + +_NOT_CONFIGURED_MSG = ( + "IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и " + "AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в " + ".env.runtime AUTH_DB_PASSWORD (пароль роли auth_app; остальные части DSN — " + "AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER — имеют прод-дефолты), " + "либо целиком AUTH_DATABASE_URL, либо верни IDENTITY_STORE=tradein (старое " + "поведение на tradein_users/tradein_sessions)." +) + +# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную +# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`. +_MALFORMED_DSN_MSG = ( + "DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан " + "явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема " + "обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ " + "подставлен: в нём пароль роли auth_app." +) + +# Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом +# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты +# уходят в threadpool). Без лока два одновременных первых запроса создали бы два +# engine — то есть два независимых пула коннектов, один из которых потеряется. +_LOCK = threading.Lock() +_engine: Engine | None = None +_session_factory: sessionmaker[Session] | None = None + + +def _build() -> tuple[Engine, sessionmaker[Session]]: + """Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка. + + DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*) — + здесь только «пусто или нет» и создание engine. + """ + dsn = settings.resolved_auth_database_url + if not dsn: + raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG) + try: + engine = create_engine(dsn, pool_pre_ping=True, future=True) + except (ArgumentError, ValueError): + # ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до + # `int(port)` и падает с `invalid literal for int() with base 10: 'w'`, + # где 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None` + # обязателен: он гасит цепочку, иначе исходная ошибка (а с ней и этот + # кусок) печатается в traceback как «During handling of...». + raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None + factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) + return engine, factory + + +def _ensure_built() -> tuple[Engine, sessionmaker[Session]]: + global _engine, _session_factory + if _engine is not None and _session_factory is not None: + return _engine, _session_factory + with _LOCK: + if _engine is None or _session_factory is None: + _engine, _session_factory = _build() + return _engine, _session_factory + + +def get_auth_engine() -> Engine: + """Engine БД `auth` (создаётся при первом вызове). + + Raises: + AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни + AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. + """ + engine, _ = _ensure_built() + return engine + + +def get_auth_session_factory() -> sessionmaker[Session]: + """Session-factory БД `auth` (создаётся при первом вызове). + + Raises: + AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни + AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. + """ + _, factory = _ensure_built() + return factory + + +@contextmanager +def auth_session() -> Iterator[Session]: + """Сессия к БД `auth`, закрывается на выходе из блока. + + Прямой вызов из роутов/сервисов НЕ предполагается — ходи через + `app.services.identity_store.identity_session()`, он один знает, какая БД + сейчас является реестром. + """ + factory = get_auth_session_factory() + with factory() as db: + yield db + + +def reset_auth_db() -> None: + """Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты). + + Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а + держать в это время лок незачем — ссылки на него уже сняты. + """ + global _engine, _session_factory + with _LOCK: + stale = _engine + _engine = None + _session_factory = None + if stale is not None: + stale.dispose() diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 2b2d264e..e2d55fa3 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1,10 +1,35 @@ """Минимальный settings для standalone trade-in MVP.""" from typing import Literal +from urllib.parse import quote -from pydantic import Field +from pydantic import Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ────── +# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)` +# и как запасное значение, если переменная окружения задана пустой строкой +# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`). +# +# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ +# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис +# `postgres` (container_name `tradein-postgres`, сети `tradein-net` + +# `gendesign_shared`) и собирает им продуктовый DATABASE_URL — +# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на +# постгресе главного стека: корневой docker-compose.prod.yml вешает своему +# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас +# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому +# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в +# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра. +# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в +# корневом compose существует только ради SSH-туннеля с хоста и к этому пути +# отношения не имеет. +_AUTH_DB_DEFAULT_HOST = "gendesign-postgres" +_AUTH_DB_DEFAULT_PORT = 5432 +_AUTH_DB_DEFAULT_NAME = "auth" +# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege). +_AUTH_DB_DEFAULT_USER = "auth_app" + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -49,11 +74,226 @@ class Settings(BaseSettings): default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET" ) - # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/ - # backfill_house_coords.py + audit_address_mismatch.py + main backend - # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F). - yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации - yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) + # ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ──── + # Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret + # (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт + # намеренно пустой, чтобы прод-контейнер не падал на старте до того как + # секрет проставлен в .env.runtime. ENV: SESSION_SECRET. + session_secret: str = Field(default="", validation_alias="SESSION_SECRET") + # Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header). + session_cookie_name: str = Field( + default="tradein_session", validation_alias="SESSION_COOKIE_NAME" + ) + # TTL сессии в часах. Дефолт 720ч (30 дней). + session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS") + # "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны); + # "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+. + auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE") + # Rate-limit на /login: не более login_rate_limit попыток за + # login_rate_limit_window_s секунд на ключ (обычно IP или username). + login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT") + login_rate_limit_window_s: int = Field( + default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" + ) + # Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571). + # Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого + # нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО + # имя, поэтому попытки со всех адресов складываются. + # + # Превышение порога НЕ блокирует учётку (это был бы вектор DoS против + # конкретного человека — злоумышленник выключал бы чужой вход по своему + # желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час + # выбран так, чтобы живой человек с опечатками до него не доходил. + login_username_fail_threshold: int = Field( + default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD" + ) + login_username_fail_window_s: int = Field( + default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S" + ) + # Потолок задержки одного ответа. Держим невысоким сознательно: задержка — + # это ещё и цена, которую платит легитимный владелец имени, пока его + # перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг». + login_username_throttle_max_delay_s: float = Field( + default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S" + ) + # ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ──── + # Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши — + # `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в + # `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были + # единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при + # стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см. + # `app.core.password.verify_password_bounded`. + # + # `workers` — это и есть потолок темпа: не больше workers/282мс проверок в + # секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы + # ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала + # блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл + # только вместе с осознанным ответом «во сколько раз мы согласны ускорить + # перебор ради параллельных входов». + # ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must + # be greater than 0») — контейнер уходит в crash-loop, и причина видна только + # в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля. + login_password_verify_workers: int = Field( + default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS" + ) + # Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт + # очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут + # workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает + # соединение к БД (сессия реестра открыта после SELECT в + # `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная + # очередь выбрала бы пул и положила API ровно так же, как блокировка цикла, + # только другим способом. 4 из 15 соединений и худшее ожидание + # 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает. + # ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход + # получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего: + # потолок — это workers, а очередь без границы выбирает пул соединений к БД. + login_password_verify_max_inflight: int = Field( + default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT" + ) + + # ── Эпик «единый вход»: общий реестр людей в БД `auth` ───────────────────── + # DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и + # «Птицы» (Site Finder); схема — data/sql/auth/001-004. + # + # ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не + # заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые + # сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт) + # к этой БД не обращается ни одна строка кода: engine не создаётся, + # соединение не открывается, пустой DSN на старте ничего не роняет — см. + # app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL. + # + # Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url` + # ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается + # из частей. Явное значение, если оно есть, по-прежнему выигрывает. + auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL") + + # ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ──────────────────── + # Пароль роли auth_app уже лежит в .env.runtime отдельной переменной + # AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить + # ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок + # целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах: + # сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком + # (аутентификация к БД `auth` отваливается для всех сразу). + # + # ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из + # backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает + # tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml). + # Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо + # глазами, чего нельзя сказать про пароль, замурованный внутрь DSN. + # + # Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме + # IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD. + # + # SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и + # обёртка бесплатно закрывает канал утечки, которого не видно глазами — + # `repr(settings)` и `settings.model_dump()` печатают обычные str-поля + # ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни + # `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False), + # но появиться такой рендер может в любой момент и тихо — с SecretStr он + # напечатает `SecretStr('**********')`. Значение достаётся ровно в одном + # месте — `.get_secret_value()` в резолвере ниже. + # ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str — + # это предсуществующее положение, а не «здесь безопасно, а там нет». + auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD") + # Остальные части — с дефолтами, верными для прод-стека (см. константы выше). + # Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST= + # localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля). + # ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER. + auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST") + auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT") + auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME") + auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER") + + @field_validator("auth_db_port", mode="before") + @classmethod + def _blank_port_means_default(cls, value: object) -> object: + """`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте. + + Симметрия с host/name/user, у которых пустое значение переменной падает + обратно на дефолт в резолвере. Для порта того же добиться нельзя: он + типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а + `settings = Settings()` выполняется на уровне модуля — то есть + `AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте + конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме, + включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни + одного обращения — ровно тот инвариант «дефолт не трогаем», который + держит остальной код. + + Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и + заполняет только пароль — остальные строки остаются пустыми намеренно. + + `mode="before"` — потому что вмешаться надо ДО приведения к int. + Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: + это опечатка со смыслом, а не «оставил пустым». + """ + if isinstance(value, str) and not value.strip(): + return _AUTH_DB_DEFAULT_PORT + return value + + @property + def resolved_auth_database_url(self) -> str: + """DSN БД `auth` — единственный источник правды для `app.core.auth_db`. + + Приоритет: + 1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда. Обратная + совместимость (так настроено «до») плюс аварийный обход: если DSN + понадобился нестандартный (другой хост, sslmode, пул-байпас), его + можно вписать целиком, не трогая код. + 2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей. + 3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка + сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит + никто. Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db` + и только когда реестр реально понадобился. + + ⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст + исключений, не отдавать наружу (`/health`, `/debug`, метрики). + + Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, + `#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@` + сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку, + либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется + имя пользователя. + + А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy + раскодирует обратно только userinfo (user/password), а path отдаёт как + есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное + `c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту + %-кодирование тоже только мешает — оно поломало бы IPv6-скобки. + """ + explicit = self.auth_database_url.strip() + if explicit: + return explicit + + # `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это + # опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится): + # ведущий/хвостовой пробел может быть частью настоящего пароля. + # Единственная точка распаковки SecretStr во всём коде — см. поле выше. + password = self.auth_db_password.get_secret_value() + if not password.strip(): + return "" + + user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="") + secret = quote(password, safe="") + host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST + port = self.auth_db_port + name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME + # Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3; + # `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в + # зависимостях нет). + return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}" + + # Где живут identity (люди + сессии): + # "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions + # (ровно сегодняшний прод, поведение не меняется); + # "auth" — БД auth, таблицы users/sessions (единый реестр). + # Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и + # перенесены данные. Дефолт = старое поведение: включить новый путь можно + # исключительно явной сменой этого флага. Единственный потребитель — + # app.services.identity_store. ENV: IDENTITY_STORE. + identity_store: Literal["tradein", "auth"] = Field( + default="tradein", validation_alias="IDENTITY_STORE" + ) + # для User-Agent в Nominatim (Nominatim Usage Policy) contact_email: str = "erginrajpopxbe@outlook.com" @@ -466,63 +706,67 @@ class Settings(BaseSettings): "www.domclick.ru", } - # ── Scraper mobile proxy (#806) ────────────────────────────────────────── - # Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями: - # Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами. + # ── Scraper mobile proxy (#806, #2616 шаг 2) ───────────────────────────── + # Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями: + # Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя. # Пусто = прямое подключение (dev/staging без прокси). # - # Приоритет ENV-переменных (precedence): - # 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым. - # 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже - # настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806). - # property `scraper_proxy_url` реализует эту логику; используй его везде. + # #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/ + # YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали + # на закрытые аккаунты (407/connection refused, проверено вживую #2613). + # SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров. # validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него # pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup). scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL") - avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback) @property def scraper_proxy_url(self) -> str | None: - """Единый прокси URL для всех scraper-сессий (Avito + Cian). + """Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex). - Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение). - Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env. + Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL + fallback снят — мёртвая mobileproxy-переменная). """ - return self.scraper_proxy_url_env or self.avito_proxy_url + return self.scraper_proxy_url_env - # changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при - # детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу). - # ENV: AVITO_PROXY_ROTATE_URL. - avito_proxy_rotate_url: str | None = None - # Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу). - # #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной - # ротации на старте (Datadome ban recovery). + # ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ──────── + # Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy + # (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена + # (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip / + # _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены: + # `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation + # state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget) + # — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без + # затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница + # asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания). + # Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611). avito_proxy_max_rotations: int = 4 - # Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP. - # ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S. avito_proxy_rotate_settle_s: float = 9.0 - # #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout - # делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая. - # Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s. - # ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS. proxy_rotate_attempt_timeout_s: float = 8.0 proxy_rotate_attempts: int = 3 + # ── ASocks pool-proxy rotation (#2600) ─────────────────────────────────── + # Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip + # (app.services.proxy_rotation). Документированный публичный API (GET + # /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает — + # подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка + # веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное + # решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy + # логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех + # прокси недоступна (rotate_proxy возвращает внятный отказ, не падает). + # ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе. + asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN") + # #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses, # ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний. # False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED. avito_serp_ok_not_banned: bool = True - # ── Cian dedicated mobile proxy (separate egress from Avito) ────────────── - # Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция - # за единственный egress → взаимные таймауты/баны при параллельных прогонах. - # Отдельный прокси для Cian устраняет contention. Если не задан — fallback на - # общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL. - cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL") - # changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан — - # fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL. - cian_proxy_rotate_url: str | None = None - # Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations. + # ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ── + # Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на + # общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён, + # cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations + # остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex + # (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op). # ENV: CIAN_PROXY_MAX_ROTATIONS. cian_proxy_max_rotations: int = 4 @@ -542,25 +786,21 @@ class Settings(BaseSettings): @property def cian_proxy_url(self) -> str | None: - """Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback).""" - return self.cian_proxy_url_env or self.scraper_proxy_url + """Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider + override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig + совместимости).""" + return self.scraper_proxy_url - # ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ──────── - # Отдельный прокси для Yandex устраняет contention при параллельных прогонах. - # Если не задан — fallback на общий scraper_proxy_url (backward-compat). - # ENV: YANDEX_PROXY_URL. - yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL") - # changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан — - # fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL. - yandex_proxy_rotate_url: str | None = None - # Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations. + # ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ── + # Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт. + # yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта. # ENV: YANDEX_PROXY_MAX_ROTATIONS. yandex_proxy_max_rotations: int = 4 @property def yandex_proxy_url(self) -> str | None: - """Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback).""" - return self.yandex_proxy_url_env or self.scraper_proxy_url + """Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url).""" + return self.scraper_proxy_url # full_load повторный прогон в день пропускает листинги уже обновлённые сегодня # (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда diff --git a/tradein-mvp/backend/app/core/fdw.py b/tradein-mvp/backend/app/core/fdw.py index ec3cbf78..a5892289 100644 --- a/tradein-mvp/backend/app/core/fdw.py +++ b/tradein-mvp/backend/app/core/fdw.py @@ -8,6 +8,7 @@ This helper: - applies idempotent CREATE or ALTER mapping on every backend startup so password rotation through .env.runtime is picked up after restart. """ + from __future__ import annotations import logging @@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None: logger.warning( "GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping " "(gendesign_cad_buildings queries will fail; cadastral lookups will " - "fall back to Yandex/Nominatim)" + "fall back to Nominatim)" ) return @@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None: ).first() if exists is None: - db.execute(text( - f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " - f"OPTIONS (user 'tradein_fdw_reader', password '{password}')" - )) + db.execute( + text( + f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " + f"OPTIONS (user 'tradein_fdw_reader', password '{password}')" + ) + ) logger.info("created FDW user mapping for gendesign_remote") else: - db.execute(text( - f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " - f"OPTIONS (SET password '{password}')" - )) + db.execute( + text( + f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " + f"OPTIONS (SET password '{password}')" + ) + ) logger.info("refreshed FDW user mapping password for gendesign_remote") try: diff --git a/tradein-mvp/backend/app/core/password.py b/tradein-mvp/backend/app/core/password.py new file mode 100644 index 00000000..2cec3ada --- /dev/null +++ b/tradein-mvp/backend/app/core/password.py @@ -0,0 +1,254 @@ +"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549). + +bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) — это silent-truncation +дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково). +`hash_password` явно ловит это и падает с ValueError вместо тихого поведения. +`verify_password` на длинном пароле возвращает False (не raise) — сравнение +паролей не должно ронять запрос авторизации. + +#2665: из `async def` зови ТОЛЬКО `verify_password_bounded` — см. её docstring. +Синхронный `verify_password` остаётся для sync-кода (сидов, тестов, CLI) и как +тело, которое исполняется в пуле. + +Правило про пул относится к СВЕРКЕ, не к хешированию. `hash_password` — тот же +cost 12 и те же ~282 мс на цикле — сознательно остаётся синхронным в +`app/api/v1/team.py` (заведение сотрудника, смена пароля): это редкая операция +АУТЕНТИФИЦИРОВАННОГО менеджера, её нельзя вызвать анонимно и потому нельзя +превратить в поток. Станет их много — переносить тем же приёмом. +""" + +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor + +import bcrypt + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_BCRYPT_MAX_BYTES = 72 +_BCRYPT_ROUNDS = 12 + + +def hash_password(plain: str) -> str: + """Хеширует пароль через bcrypt (rounds=12). + + Raises: + ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8 + (bcrypt тихо обрезает — недопустимо, см. модульный docstring). + """ + if not plain: + raise ValueError("password must not be empty") + + encoded = plain.encode("utf-8") + if len(encoded) > _BCRYPT_MAX_BYTES: + raise ValueError( + f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})" + ) + + salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS) + hashed = bcrypt.hashpw(encoded, salt) + return hashed.decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + """Сверяет пароль с bcrypt-хешем. + + Пустой пароль или пароль длиннее 72 байт в UTF-8 → False (не raise — + verify — это false/true проверка на этапе логина, а не валидация ввода). + """ + if not plain or not hashed: + return False + + encoded = plain.encode("utf-8") + if len(encoded) > _BCRYPT_MAX_BYTES: + return False + + try: + return bcrypt.checkpw(encoded, hashed.encode("utf-8")) + except (ValueError, TypeError) as e: + # Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login. + logger.warning("verify_password: malformed hash rejected: %s", e) + return False + + +class PasswordVerifyOverloadedError(RuntimeError): + """Свободных слотов на проверку пароля нет. Вызывающий обязан ответить 429.""" + + +# Пул, в котором крутится bcrypt. `max_workers` — не тюнинг пропускной +# способности, а САМ ПОТОЛОК ТЕМПА: проверок в секунду не больше, чем +# workers / 282мс, независимо от числа соединений. Читается один раз на импорте +# — размер пула по определению статичен (см. `login_password_verify_workers`). +_VERIFY_POOL = ThreadPoolExecutor( + max_workers=settings.login_password_verify_workers, + thread_name_prefix="pw-verify", +) + +# Сколько проверок сейчас в работе ИЛИ ждут очереди в пуле. Обычный int без +# лока — намеренно: и инкремент, и декремент выполняются в потоке событийного +# цикла, между чтением и записью нет ни одного `await`, так что чередования +# внутри пары нет. Счётчик, а не `asyncio.Semaphore`: мы никогда не ЖДЁМ на нём +# (сверх лимита — сразу отказ), а int не имеет привязки к конкретному циклу и +# потому одинаково честен под несколькими event loop'ами в тестах. +_verify_inflight = 0 + +# То же самое, но в разрезе ключа (#2714). Запись живёт РОВНО пока ключ держит +# хотя бы слот и удаляется на нуле: размер словаря ограничен числом слотов +# (`login_password_verify_max_inflight`), а не числом когда-либо виденных +# адресов — иначе перебор с ротацией IP растил бы его без границы. +_verify_inflight_by_key: dict[str, int] = {} + + +def _per_key_slot_cap() -> int: + """Сколько слотов из общего лимита разрешено ОДНОМУ ключу. + + Половина — минимальное деление, при котором один источник, сколько бы он ни + слал, физически не может занять всё: вторая половина остаётся тем, кто + приходит впервые. Настройкой не сделано сознательно — это доля, а не + величина, и подкручивать её нечем: 100% возвращает поведение, ради отказа + от которого правка написана. + + Читается на каждом вызове, а не на импорте, — как `_throttle_delay_s`: + иначе тестовый monkeypatch лимита не влиял бы на долю. + + `max(1, …)`: при `max_inflight=1` половина округлилась бы в 0, и КАЖДЫЙ вход + получал бы отказ молча (свободных слотов нет ни у кого). Молчаливый отказ + всем — ровно тот класс поломки, от которого страхует `ge=1` на самой + настройке; здесь тот же страховочный пол, но от деления. + """ + return max(1, settings.login_password_verify_max_inflight // 2) + + +async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool: + """`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665). + + ДВЕ ПОЛОВИНЫ ОДНОЙ ПРАВКИ, И ЖИВУТ ОНИ ЗДЕСЬ ВМЕСТЕ НЕ ИЗ ЛЮБВИ К ПОРЯДКУ. + Порознь каждая делает хуже, чем было: + - вынести bcrypt в пул, не поставив потолок → перебор УСКОРЯЕТСЯ (замер + ниже: 3.6/с → 16/с на дефолтном executor'е); + - поставить потолок, не вынося bcrypt → 282 мс простоя всего API на каждую + попытку остаются. + Поэтому единственная точка выноса в поток и единственная точка учёта слотов — + одна и та же функция: состояние «вынесено, но потолка нет» невыразимо. + + Замер в прод-контейнере (2026-08-06, cost 12, все живые хеши `$2b$12$`): + verify_password = 282 мс медиана; + вызов прямо в `async def` — 3.6 проверки/с, стойло событийного цикла 836 мс + (это и был «потолок» — случайный, ценой отказа в обслуживании всего API); + `asyncio.to_thread` без потолка — 16 проверок/с, стойло 6 мс. + Отсюда дефолт `workers=1`: потолок остаётся тем же ~3.5/с, что был, а API + перестаёт стоять. Числа перепроверяемы: tests/test_password.py. + + Потолок держится ПРОЦЕССОМ, а не общим хранилищем. Это проверено, а не + предположено: прод-бэкенд запущен `uvicorn app.main:app` без `--workers` + (один процесс), а `REDIS_URL` в окружении tradein-backend НЕ ЗАДАН вовсе + (`printenv | grep -c ^REDIS_URL=` → 0, находка эпика #2674 — кэш поиска всю + жизнь стучится в localhost и получает отказ). Потолок на Redis был бы + потолком, который молча не работает. + Ceiling: появятся `--workers N` (или `WEB_CONCURRENCY=N` в `.env.runtime` — + uvicorn читает число процессов и оттуда, а файл правится руками на VPS) — + темп множится на N, как и у соседних in-memory лимитеров в + app/api/v1/auth.py; тогда потолок надо переносить в общее хранилище, + предварительно убедившись, что оно реально доступно. + + ДОЛЯ НА КЛЮЧ (#2714). Слоты — общий котёл, и потолок исправно бил по своим: + пока флуд держал все четыре, легитимный вход с ВЕРНЫМ паролем получал 429 + столько раз, сколько пытался. Поэтому *key* (у единственного вызывающего — + IP клиента) не берёт больше `_per_key_slot_cap()`: сколько бы один источник + ни слал, половина ёмкости остаётся тем, кто приходит впервые. Учёт по ключу + живёт ЗДЕСЬ ЖЕ и отдаётся тем же `_release_verify_slot` — инвариант «одна + точка выноса = одна точка учёта» не делится надвое. + + Чего это НЕ делает, и это не оговорка ради приличия. Ключом может быть + только IP, а IP: + - подделывается, если между нами и клиентом окажется ещё один прокси + (сейчас доверенный хоп ровно один — Caddy, `ratelimit._client_ip` берёт + правый элемент XFF; появится второй — ключ станет клиентским вводом); + - разделяется: за NAT/корпоративным шлюзом вся организация приходит с + одного адреса и делит одну долю с чужим перебором. СОСЕДЯМ ПО АДРЕСУ + СТАЛО ХУЖЕ, и это честный размен, а не побочный эффект: при флуде в + 3 запроса/с с того же адреса свои входят 69% попыток против 94% до + правки, а порог, за которым сосед перестаёт входить, падает с ~14 до + ~7 запросов/с. Взамен вход С ЧУЖИХ адресов идёт 100% против 37%; + размен принят сознательно — офис за одним NAT это единицы адресов, + а «все остальные» это все; + - меняется: ботнет или ротация прокси дают злоумышленнику столько ключей, + сколько ему нужно, и доля на ключ перестаёт быть ограничением. + То есть это ПОДНИМАЕТ СТОИМОСТЬ атаки (одного адреса больше не хватает, + чтобы закрыть вход всем), но не закрывает её. Закрывают принципиально + только доказательство работы на входе или второй фактор — отдельный разговор + и отдельная цена. + + Raises: + PasswordVerifyOverloadedError: очередь на проверку заполнена + (`login_password_verify_max_inflight`) ЛИБО *key* уже держит свою + долю (`_per_key_slot_cap`). Отказ мгновенный: ждать нельзя, ждущий + запрос держит соединение к БД. Оба случая неразличимы снаружи + намеренно — отказ приходит ДО сверки и потому ничего не сообщает о + том, существует ли учётка. + """ + global _verify_inflight + + if _verify_inflight >= settings.login_password_verify_max_inflight: + raise PasswordVerifyOverloadedError + if _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap(): + raise PasswordVerifyOverloadedError + + loop = asyncio.get_running_loop() + _verify_inflight += 1 + _verify_inflight_by_key[key] = _verify_inflight_by_key.get(key, 0) + 1 + try: + work = _VERIFY_POOL.submit(verify_password, plain, hashed) + except BaseException: + # Работа в пул НЕ встала — колбэка не будет, слот отдаём здесь. Иначе + # утёкший слот навсегда отнимает у входа часть и без того малой ёмкости. + _release_verify_slot(key) + raise + + # Слот освобождает ЗАВЕРШЕНИЕ РАБОТЫ, а не выход из этой корутины. Отмена + # (клиент отвалился, таймаут) прекращает корутину, но УЖЕ НАЧАТУЮ сверку не + # снимает — поток занят ею все 282 мс. Отдавай мы слот в `finally`, на это + # время слот считался бы свободным: одновременно работающих сверок стало бы + # больше, чем разрешено, и очередь пула поехала бы вслед за ними. + # (Ещё не начатую работу отмена как раз снимает — `cancel()` пробрасывается + # на future пула, — так что вреда от неё нет; проблема ровно в начатой.) + # + # Именно поэтому колбэк висит на future ПУЛА, а не на обёртке из + # `run_in_executor`: у обёртки «готово» наступает и при отмене — тест + # `test_bounded_slot_freed_by_the_work_not_by_cancellation` ловит эту разницу. + work.add_done_callback(lambda _f: _schedule_verify_slot_release(loop, key)) + return await asyncio.wrap_future(work) + + +def _schedule_verify_slot_release(loop: asyncio.AbstractEventLoop, key: str) -> None: + """Возвращает слот по факту завершения работы в пуле (см. вызывающую). + + Колбэк future пула исполняется В ПОТОКЕ ПУЛА, а счётчики — собственность + потока событийного цикла (на том и держится арифметика без лока), поэтому + декремент переносим в цикл через `call_soon_threadsafe`. + """ + try: + loop.call_soon_threadsafe(_release_verify_slot, key) + except RuntimeError: + # Цикл уже закрыт (остановка процесса) — освобождать нечего и некому. + logger.debug("verify slot release skipped: event loop is closed") + + +def _release_verify_slot(key: str) -> None: + """Единственное место, где слот отдают: и общий счётчик, и счётчик ключа. + + Оба — одним движением и здесь же, а не по одному на каждом пути выхода: + разъедься они, и достаточно забыть одну строчку, чтобы ключ навсегда унёс + с собой долю ёмкости, которую никто уже не вернёт. + """ + global _verify_inflight + _verify_inflight -= 1 + left = _verify_inflight_by_key.get(key, 0) - 1 + if left > 0: + _verify_inflight_by_key[key] = left + else: + _verify_inflight_by_key.pop(key, None) diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index 2b809ddb..f5f3fe04 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -114,8 +114,13 @@ class SlidingWindowLimiter: return self._window_s - (now - bucket[0]) return None - def record(self, key: str) -> None: - """Регистрирует одну успешную попытку под *key*.""" + def record(self, key: str) -> int: + """Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё. + + Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login + (#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа + пропорционально. Значение можно игнорировать — `check()` так и делает. + """ now = time.monotonic() bucket = self._hits[key] self._prune(bucket, now) @@ -125,6 +130,7 @@ class SlidingWindowLimiter: if len(self._hits) > 10000: for k in [k for k, v in self._hits.items() if not v]: del self._hits[k] + return len(bucket) def check(self, key: str) -> float | None: """Комбинированная проверка+регистрация (peek+record за один вызов) — diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 0596de39..e8c5fc04 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -7,12 +7,22 @@ manually". The copy drifted: it was missing the #2213 ``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has, so a regression in that check would NOT have failed CI. -This module holds the real guard with no DB/lifespan/scheduler side effects -(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at -import time beyond requiring ``DATABASE_URL`` in the environment for -``Settings()``). ``app/main.py`` and the test apps both import THIS module, -so tests exercise the exact production code path instead of a copy that can -silently fall out of sync. +This module holds the real guard. Historically it had "no DB/lifespan/scheduler +side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free +at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request +DB round trip via ``app.services.identity_store.identity_session`` — но ТОЛЬКО +когда запрос реально несёт session-cookie +(``request.cookies.get(settings.session_cookie_name)``); без cookie (весь +существующий тестовый трафик, legacy Caddy trusted-header запросы) ветка не +выполняется — ноль новых DB-побочных эффектов для старых путей. ``app/main.py`` +and the test apps both import THIS module, so tests exercise the exact +production code path instead of a copy that can silently fall out of sync. + +Сессия открывается через ``identity_session()``, а не через +``app.core.db.SessionLocal`` напрямую: guard — middleware, FastAPI-DI здесь нет, +а реестр людей при ``IDENTITY_STORE=auth`` лежит в другой БД. В дефолтном режиме +``identity_session()`` открывает ровно ``app.core.db.SessionLocal()`` — тот же +коннект-пул и то же поведение, что до эпика «единый вход». """ from __future__ import annotations @@ -21,12 +31,15 @@ import logging import re import secrets from collections.abc import Awaitable, Callable +from typing import Any from fastapi import Request 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.identity_store import identity_session logger = logging.getLogger(__name__) @@ -40,7 +53,37 @@ logger = logging.getLogger(__name__) # Public paths без auth (/health, /docs, /openapi.json) пропускаем — # X-Authenticated-User там не приходит из Caddy. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") -_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) +# #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как +# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей +# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой +# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware +# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский +# auth-required gate, не остальные защиты. +# +# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным +# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку — +# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и +# достучаться из продукта не могла). Ветка НЕ трогает авторизованные +# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой, +# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя +# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то +# логином. +_PUBLIC_PATHS = frozenset( + { + "/health", + "/docs", + "/redoc", + "/openapi.json", + "/api/v1/auth/login", + "/api/v1/auth/logout", + # NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy + # срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это + # /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое. + "/api/v1/trade-in/support/anon/messages", + "/api/v1/trade-in/support/anon/unread", + "/api/v1/trade-in/support/anon/read", + } +) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для # scope-проверки восстанавливаем внешний путь. @@ -51,6 +94,77 @@ _EXTERNAL_PREFIX = "/trade-in" _RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand") +def _db_glob_match(pattern: str, path: str) -> bool: + """Мини-матчер для фиксированного набора DB-role паттернов + (``app.services.auth_session.DB_ROLE_PATHS`` — только формы ``/**`` и + ``/**``, не нужна полная semantics ``app.core.auth._glob_to_regex`` + — тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний + импорт private-символа оттуда увеличивал бы drift-риск).""" + if pattern == "/**": + return True + if pattern.endswith("/**"): + prefix = pattern[: -len("/**")] + return path == prefix or path.startswith(prefix + "/") + return path == pattern + + +def _db_role_path_allowed(role: str, path: str) -> bool: + paths, deny = get_db_role_scope(role) + if any(_db_glob_match(p, path) for p in deny): + return False + return any(_db_glob_match(p, path) for p in paths) + + +def _propagate_authenticated_user(request: Request, username: str) -> None: + """Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не + только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/ + ``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую, + #2213/#2550) и downstream route-хендлеры (читающие его через FastAPI + ``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера — без правок в каждом + из этих мест по отдельности (минимально инвазивный способ). + + #2552 post-review fix (CRITICAL): раньше это была skip-if-present + мутация (``if request.headers.get(...): return``) — сессия резолвилась + ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User`` + (который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО + downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог + подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к + данным victim в ~15 роутах, читающих заголовок напрямую + (``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``, + ``support.py``) — работало в ОБОИХ auth_mode (dual и db_only), т.к. эти + хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про + сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если + сессия резолвлена — полная перезапись, не skip. + + Механизм: ``request.scope`` — ОДИН и тот же dict-объект, прокинутый по + ссылке через весь ASGI call chain (Starlette не копирует scope между + слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна: + - downstream call_next() цепочке (ExceptionMiddleware → Router → + endpoint) — т.к. rbac_guard мутирует scope ДО вызова call_next(); + - ``RequestAuditMiddleware`` — он внешний относительно rbac_guard + (см. app/main.py: последний ``add_middleware`` оборачивает + предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()`` + отработал весь внутренний стек, включая эту мутацию. + + ASGI header-имена — всегда lowercase bytes (см. ASGI spec), поэтому + фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от + регистра, в котором его прислал клиент (Starlette уже нормализует). + + Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно + rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот + guard успевает отработать) — для ЭТОГО конкретного запроса сессионный + юзер лимитируется по IP, а не по username (per-user множитель не + применяется). Не регрессия (IP-лимит применялся бы и раньше — до + добавления session-auth такие запросы вообще были 401), просто более + строгий бюджет специфично для session-cookie-запросов; при необходимости + точного per-user квотинга для DB-юзеров — переносить резолв сессии выше + RateLimit в app/main.py отдельным issue. + """ + request.scope["headers"] = [ + (k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user" + ] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))] + + async def rbac_guard( request: Request, call_next: Callable[[Request], Awaitable[Response]], @@ -59,40 +173,88 @@ async def rbac_guard( if path in _PUBLIC_PATHS: return await call_next(request) - username = request.headers.get("X-Authenticated-User") - if not username: - return JSONResponse( - status_code=401, - content={"detail": "no authenticated user (Caddy basic_auth required)"}, - ) + username: str | None = None + role: str | None = None + from_session = False - # #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User - # ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env). - # Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401. - # Constant-time compare против timing-атак. Пусто = защита не активна (fail-open). - secret = settings.tradein_internal_auth_secret - if secret: - provided = request.headers.get("X-Internal-Auth-Secret", "") - if not secrets.compare_digest(provided, secret): - logger.warning( - "RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret " - "на %s — возможная подделка заголовка мимо Caddy", - username, - path, - ) + # #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще — + # request.cookies.get() возвращает None без единого похода в БД (ноль + # side-effects для всего существующего трафика без cookie). + token = request.cookies.get(settings.session_cookie_name) + if token: + session_user: dict[str, Any] | None = None + try: + with identity_session() as db: + session_user = get_session_user(db, token) + except Exception: + # Сюда попадает и AuthDatabaseNotConfiguredError (IDENTITY_STORE=auth + # без AUTH_DATABASE_URL): резолв сессии не состоялся, дальше работает + # тот же путь, что и при любом сбое БД, — auth_mode решает, пускать ли + # legacy trusted-header. + # + # ⚠️ Этот except НЕ должен быть тем, что ловит сломанный DSN: молча + # деградировать в legacy trusted-header означало бы раздавать права + # из roles.yaml в обход реестра (включая аккаунты с access_state + # 'disabled'/'trial_expired'), причём сутками — продуктовая БД жива, + # приложение работоспособно, сигнал только в логах. Поэтому + # конфигурацию проверяет lifespan (app/main.py): при + # IDENTITY_STORE=auth пустой DSN роняет СТАРТ. Здесь остаётся второй + # рубеж — реестр, отвалившийся уже после успешного старта, не имеет + # права отдавать 500. + logger.exception("RBAC: session lookup failed for %s", path) + if session_user is not None: + username = session_user["username"] + role = session_user["role"] + from_session = True + _propagate_authenticated_user(request, username) + + if not from_session: + # auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ + # отключён, даже если валидный X-Authenticated-User присутствует. + if settings.auth_mode != "dual": return JSONResponse( status_code=401, - content={"detail": "invalid or missing internal auth secret"}, + content={"detail": "valid session required"}, ) - try: - role = get_role(username) - except KeyError: - logger.warning("RBAC: unknown user %r tried %s", username, path) - return JSONResponse( - status_code=403, - content={"detail": "user not in roles config"}, - ) + # ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ---- + username = request.headers.get("X-Authenticated-User") + if not username: + return JSONResponse( + status_code=401, + content={"detail": "no authenticated user (valid session required)"}, + ) + + # #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User + # ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env). + # Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401. + # Constant-time compare против timing-атак. Пусто = защита не активна (fail-open). + secret = settings.tradein_internal_auth_secret + if secret: + provided = request.headers.get("X-Internal-Auth-Secret", "") + if not secrets.compare_digest(provided, secret): + logger.warning( + "RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret " + "на %s — возможная подделка заголовка мимо Caddy", + username, + path, + ) + return JSONResponse( + status_code=401, + content={"detail": "invalid or missing internal auth secret"}, + ) + + try: + role = get_role(username) + except KeyError: + logger.warning("RBAC: unknown user %r tried %s", username, path) + return JSONResponse( + status_code=403, + content={"detail": "user not in roles config"}, + ) + + assert username is not None + assert role is not None if _ADMIN_API_RE.match(path) and role != "admin": logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path) @@ -101,25 +263,24 @@ async def rbac_guard( content={"detail": "admin only"}, ) - # #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не - # только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко- - # скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search — - # экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me, - # /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем - # внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий - # лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся). + # #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не + # только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/ + # scope-narrowed юзер не смог бы получить свою роль вовсе. if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT): external_path = _EXTERNAL_PREFIX + path - try: - allowed = is_path_allowed(role, external_path) - except Exception: - logger.exception( - "RBAC scope-check raised for %s %s (ext=%s) — fail-open", - username, - path, - external_path, - ) - allowed = True + if from_session: + allowed = _db_role_path_allowed(role, external_path) + else: + try: + allowed = is_path_allowed(role, external_path) + except Exception: + logger.exception( + "RBAC scope-check raised for %s %s (ext=%s) — fail-open", + username, + path, + external_path, + ) + allowed = True if not allowed: logger.info( "RBAC: scope-blocked %s (role=%s) from %s (ext=%s)", diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 07d58fa8..c5e2156e 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration from app.api.v1 import ( admin, audit, + auth, brand, buildings, geocode, @@ -31,8 +32,10 @@ from app.api.v1 import ( privacy_admin, search, support, + team, trade_in, ) +from app.core.auth_db import get_auth_engine from app.core.config import settings from app.core.db import SessionLocal from app.core.fdw import ensure_fdw_user_mapping @@ -107,6 +110,40 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)" ) + # #2552: session_secret зарезервирован на будущее (напр. подписанные токены) — + # opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session) + # НЕ требуют подписи, их валидность проверяется исключительно наличием строки + # в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен + # ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы + # прод не остался без него незамеченно до момента, когда он реально понадобится. + if not settings.session_secret: + logger.warning( + "SESSION_SECRET пуст — не блокирует старт (opaque session-токены не " + "требуют подписи), но задай его в .env.runtime до появления фич, " + "которым подпись реально нужна" + ) + + # Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть + # СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД + # недоступна»: продуктовая БД жива, приложение полностью работоспособно и + # может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError + # вместе с любым другим сбоем резолва сессии и падает в legacy + # trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил + # Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с + # access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный + # деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра. + # + # На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня) + # ветка не выполняется, engine БД `auth` не создаётся, пустой + # AUTH_DATABASE_URL по-прежнему не ошибка. + if settings.identity_store == "auth": + # Наружу летит AuthDatabaseNotConfiguredError с внятным текстом + # (app.core.auth_db); create_engine к серверу не ходит, так что это + # проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер + # по-прежнему не мешает старту. + get_auth_engine() + logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован") + # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: @@ -159,6 +196,7 @@ def health() -> dict[str, str]: return {"status": "ok", "environment": settings.environment} +app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) @@ -170,3 +208,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) +app.include_router(team.router, prefix="/api/v1/team", tags=["team"]) diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 9d68d457..51c01e94 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -48,8 +48,11 @@ _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]" _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b") # Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit): -# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py -# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`). +# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., +# admin.rotate_proxy_ip) несла провайдерский API-ключ в query +# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая +# подписка) — редактор оставлен как generic safety net (не ключ-based, любой +# будущий query-секрет с распространённым именем параметра тоже покрыт). # Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше: # 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url), # sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе @@ -58,13 +61,14 @@ _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b") # span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг # когда-нибудь включат. # 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие -# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`, -# `exc` в rotate_proxy_ip) в traceback открытым текстом. +# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback +# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом +# admin.rotate_proxy_ip). # Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет # может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ # завязано на конкретного провайдера — покрывает любой query-параметр из # общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/ -# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально +# access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально # другой провайдер, другое имя параметра). _URL_SECRET_QUERY_RE = re.compile( r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|" diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py index 62d9e5f2..d6cd6ae7 100644 --- a/tradein-mvp/backend/app/schemas/search.py +++ b/tradein-mvp/backend/app/schemas/search.py @@ -40,7 +40,9 @@ class SearchParams(BaseModel): floors_total_max: int | None = Field(default=None, ge=1) # --- Quality / cross-source --- - has_kadastr: bool = False + # has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк, + # фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует, + # так что старые клиенты не ломаются. sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None multi_source_only: bool = False require_avito: bool = False diff --git a/tradein-mvp/backend/app/schemas/team.py b/tradein-mvp/backend/app/schemas/team.py new file mode 100644 index 00000000..6ed84249 --- /dev/null +++ b/tradein-mvp/backend/app/schemas/team.py @@ -0,0 +1,116 @@ +"""Pydantic-схемы team-management API (#2554, эпик #2549). + +CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')` — +manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история +оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в +`app.api.v1.team`, эти схемы — только форма запросов/ответов. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# ASCII-only — не-ASCII username ломает downstream identity-пропагацию +# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с +# errors="replace"), поэтому валидация формы обязательна на границе API, +# а не только на уровне БД. +# +# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing +# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK +# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка +# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо +# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает +# с семантикой Postgres `~`. +_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z") + + +class QuotaStatusOut(BaseModel): + """Статус месячной квоты оценок — вложен в `EmployeeOut`.""" + + model_config = ConfigDict(from_attributes=True) + + limit: int + used: int + remaining: int + unlimited: bool + + +class EmployeeCreateRequest(BaseModel): + """`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле).""" + + username: str + password: str + display_name: str | None = None + org_name: str | None = None + email: str | None = None + monthly_limit: int | None = Field(default=None, ge=1) + # Только для actor.role == admin — опциональная привязка к конкретному manager. + # Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) — + # см. app.api.v1.team.create_employee. + manager_id: int | None = None + + @field_validator("username") + @classmethod + def _validate_username(cls, v: str) -> str: + if not _USERNAME_RE.match(v): + raise ValueError( + "username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen" + ) + return v + + +class EmployeeUpdateRequest(BaseModel): + """`PATCH /employees/{id}` — частичное обновление, все поля опциональны.""" + + is_active: bool | None = None + monthly_limit: int | None = Field(default=None, ge=1) + display_name: str | None = None + org_name: str | None = None + email: str | None = None + new_password: str | None = None + + +class EmployeeOut(BaseModel): + """Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + # 'employee' | 'manager' — admin управляет обоими, manager видит только + # employee (см. app.api.v1.team, модульный docstring). Строки role='admin' + # через этот API не отдаются никогда, поэтому в Literal их нет. + role: Literal["employee", "manager"] + display_name: str | None = None + org_name: str | None = None + email: str | None = None + is_active: bool + manager_id: int | None = None + created_at: datetime + quota: QuotaStatusOut + + +class EmployeeHistoryEntry(BaseModel): + """Одна строка истории оценок сотрудника — `GET /employees/{id}/history`. + + Источник — `user_events` (event_type='estimate_request', паттерн + `app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates` + за фактическим результатом (median_price/confidence/n_analogs) — join + может не сматчиться (старая запись без estimate_id / оценка insufficient_data), + поэтому все result-поля nullable. + """ + + model_config = ConfigDict(from_attributes=True) + + estimate_id: str | None = None + address: str | None = None + area_m2: str | None = None + rooms: str | None = None + median_price: int | None = None + confidence: str | None = None + n_analogs: int | None = None + created_at: datetime diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 7e361bde..63e33cd5 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel): # geocode() (который падает на DaData-формах при мёртвом Yandex-ключе). lat: float | None = Field(default=None, ge=-90, le=90) lon: float | None = Field(default=None, ge=-180, le=180) + # #2576: город, если известен фронту (например выбран отдельным полем UI). + # Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ + # подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше + # давало уверенно неверную цену для жителей других городов области (те же + # улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле). + city_hint: str | None = Field(default=None, max_length=100) # ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest # (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер # (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional — @@ -194,6 +200,12 @@ class AggregatedEstimate(BaseModel): target_address: str | None = None # geocoded full address target_lat: float | None = None target_lon: float | None = None + # #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли + # город явно — итоговый город (и, соответственно, набор аналогов/цена) + # определил геокодер-провайдер, а не пользователь. Честный сигнал для + # UI (снизить доверие / переспросить город), НЕ персистится в БД + # (ephemeral, только для текущего POST /estimate ответа). + target_city_ambiguous: bool = False sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг # абсолютный timestamp самого свежего парсинга аналогов @@ -258,6 +270,16 @@ class AggregatedEstimate(BaseModel): # null — нет данных / оценка не построена # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None + # search_radius_m — фактический радиус (метры), по которому реально отбирались + # listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может + # ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне): + # сервер молча расширяет 1 км → 2 км при нехватке аналогов (см. + # confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте + # по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально + # использованном радиусе. None на GET-rehydrate (не персистится, старые записи) + # и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае + # fallback'ает на выбор пользователя. + search_radius_m: int | None = None # ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ── # premium_building — целевой дом признан премиальным. Источник — curated overlay # `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false- @@ -404,6 +426,11 @@ class ScheduleConfigUpdate(BaseModel): window_start_hour: int = Field(default=2, ge=0, le=23) window_end_hour: int = Field(default=5, ge=0, le=23) default_params: dict[str, Any] = Field(default_factory=dict) + # #2674: явная воля оператора по времени следующего запуска. None (умолчание) — + # «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая + # прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с + # next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом. + next_run_at: datetime | None = None # ── House analytics (house_placement_history backfill) ─────────────────────── @@ -591,6 +618,13 @@ class SalesVsListingsResponse(BaseModel): deals_with_listings: int # сколько имеют связанный listing linkage_rate_pct: float # deals_with_listings / total_deals * 100 median_discount_pct: float | None # медиана по парам с listing + # #2666: None вместе с median_discount_pct=None означает «медианы просто нет» + # (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт + # правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги + # SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается. + # Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен + # текст «почему числа нет», иначе пустое место читается как поломка виджета. + median_discount_explanation: str | None = None data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3) pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC diff --git a/tradein-mvp/backend/app/services/auth_session.py b/tradein-mvp/backend/app/services/auth_session.py new file mode 100644 index 00000000..38071bea --- /dev/null +++ b/tradein-mvp/backend/app/services/auth_session.py @@ -0,0 +1,334 @@ +"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core). + +Схема НЕ зашита: имена таблиц и имя колонки состояния доступа берутся из +`app.services.identity_store.identity_schema()` — эпик «единый вход» переводит +реестр людей с `tradein_users`/`tradein_sessions` (migration +`192_tradein_users_auth.sql`, БД tradein) на `users`/`sessions` (БД `auth`, +миграции data/sql/auth/001-004) флагом `IDENTITY_STORE`, дефолт которого = +сегодняшнее прод-поведение. Никаких других отличий между режимами у этого +модуля нет: SQL один и тот же, подставляются только имена из фиксированного +словаря `identity_store._SCHEMAS`. + +Опаковые (`secrets.token_urlsafe`) токены-сессии — не JWT, не подписаны: валидность +проверяется исключительно наличием строки + `expires_at` + состоянием доступа +юзера в БД, поэтому `SESSION_SECRET` НЕ обязателен для работы этого модуля +(зарезервирован на будущее, см. `app.core.config.Settings.session_secret` docstring). + +Все функции здесь принимают уже открытую `db: Session` — сами НЕ открывают +сессию (вызывающая сторона решает время жизни транзакции: `rbac_guard` и +роуты открывают её по-разному). ⚠️ Это ОБЯЗАНА быть сессия РЕЕСТРА +(`identity_store.identity_session()` / `Depends(get_identity_db)`), а не +`app.core.db.get_db`: при `IDENTITY_STORE=auth` запрос уйдёт в БД tradein, +где таблиц `users`/`sessions` нет. В дефолтном режиме это один и тот же объект. +Модуль остаётся тривиально unit-тестируемым — тесты просто передают +fake/real `Session`. + +Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются +через `logger` вызывающей стороной (см. `app.core.rbac.rbac_guard`, +`app.api.v1.me`), сам сервис поднимает исключения как есть (это НЕ fire-and-forget +аудит-лог вроде `app.services.user_events`, а часть auth-decision — сбой обязан +быть виден вызывающему, чтобы тот мог fail-closed). +""" + +from __future__ import annotations + +import logging +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.services.identity_store import identity_schema, to_access_state + +logger = logging.getLogger(__name__) + +# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в +# 5 минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним +# UPDATE (RBAC гоняет get_session_user на КАЖДЫЙ non-public запрос). +_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5) + +_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) — 256 бит энтропии, ~43 символа + + +def create_session( + db: Session, + user_id: int, + ip: str | None = None, + user_agent: str | None = None, +) -> str: + """Создаёт новую сессию для *user_id* и возвращает opaque-токен. + + `expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained, + как `app.services.user_events.record_event`). + """ + schema = identity_schema() + token = secrets.token_urlsafe(_TOKEN_BYTES) + db.execute( + text( + f""" + INSERT INTO {schema.sessions_table} + (token, user_id, expires_at, ip_address, user_agent) + VALUES ( + :token, :user_id, + now() + make_interval(hours => CAST(:ttl_hours AS integer)), + CAST(:ip AS inet), :user_agent + ) + """ + ), + { + "token": token, + "user_id": user_id, + "ttl_hours": settings.session_ttl_hours, + "ip": ip, + "user_agent": user_agent, + }, + ) + db.commit() + return token + + +def get_session_user(db: Session, token: str) -> dict[str, Any] | None: + """Резолвит сессионный токен в данные юзера, или None если сессия + невалидна (не найдена / истекла / доступ юзера не `active`). + + Состояние доступа: пропускает ТОЛЬКО `AccessState.ACTIVE`. Любое другое + (`disabled`, `trial_expired`, а также нераспознанное — `to_access_state` + fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной + немедленно, без ожидания TTL. Это то же решение, что и в булевой схеме + (`is_active = false` → None), просто теперь состояний больше одного: + «пробный период истёк» гасит живую сессию так же, как блокировка — иначе + сотрудник, залогиненный до истечения пробного доступа, продолжал бы + работать, а sliding-refresh продлевал бы ему сессию бесконечно. + + Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут — + продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh + (напр. read-replica) логируется и НЕ мешает вернуть валидного юзера — + это best-effort продление, а не часть решения "валидна ли сессия". + """ + if not token: + return None + + schema = identity_schema() + row = db.execute( + text( + f""" + SELECT s.user_id, s.expires_at, s.last_seen_at, + u.username, u.role, u.display_name, u.org_name, u.email, + u.{schema.access_state_column} AS access_state + FROM {schema.sessions_table} s + JOIN {schema.users_table} u ON u.id = s.user_id + WHERE s.token = :token + """ + ), + {"token": token}, + ).fetchone() + + if row is None: + return None + + now = datetime.now(UTC) + if row.expires_at is None or row.expires_at <= now: + return None + access_state = to_access_state(row.access_state) + if not access_state.can_sign_in: + return None + + if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL: + try: + db.execute( + text( + f""" + UPDATE {schema.sessions_table} + SET last_seen_at = now(), + expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer)) + WHERE token = :token + """ + ), + {"ttl_hours": settings.session_ttl_hours, "token": token}, + ) + db.commit() + except Exception: + logger.warning( + "auth_session: sliding refresh failed for user_id=%r", row.user_id, exc_info=True + ) + db.rollback() + + return { + "user_id": row.user_id, + "username": row.username, + "role": row.role, + "display_name": row.display_name, + "org_name": row.org_name, + "email": row.email, + # Всегда AccessState.ACTIVE — не-active сюда не доходит (см. выше). + # Ключ оставлен вместо прежнего `is_active`, чтобы состояние доступа во + # ВСЁМ коде называлось и выражалось одинаково. + "access_state": access_state, + } + + +def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None: + """Возвращает строку реестра по username, или None если не найден. + + Используется login-флоу (`app.api.v1.auth.login`) для password-проверки. + Отдаёт `password_hash` как есть (может быть NULL — переходный период, + см. migration 192 docstring) — вызывающая сторона решает, что с ним делать. + + `access_state` — уже `AccessState` (не сырое значение колонки): решение + «пускать / не пускать / показать экран пробного периода» принимает login, + и принимать его он обязан по ОДНОМУ понятию, а не по boolean в одном режиме + и строке в другом. Отсутствие юзера состоянием НЕ выражается (None остаётся + None) — иначе login потерял бы разницу между «нет такого логина» и + «заблокирован», а она нужна ему для выбора события аудита. + """ + schema = identity_schema() + row = db.execute( + text( + f""" + SELECT id, username, password_hash, role, + {schema.access_state_column} AS access_state, + display_name, org_name, email + FROM {schema.users_table} + WHERE username = :username + """ + ), + {"username": username}, + ).fetchone() + + if row is None: + return None + + return { + "user_id": row.id, + "username": row.username, + "password_hash": row.password_hash, + "role": row.role, + "access_state": to_access_state(row.access_state), + "display_name": row.display_name, + "org_name": row.org_name, + "email": row.email, + } + + +def revoke_session(db: Session, token: str) -> None: + """Удаляет одну сессию по токену (logout). No-op если токен не найден.""" + schema = identity_schema() + db.execute(text(f"DELETE FROM {schema.sessions_table} WHERE token = :token"), {"token": token}) + db.commit() + + +def revoke_user_sessions(db: Session, user_id: int) -> None: + """Удаляет ВСЕ сессии юзера — смена пароля и блокировка обязаны рвать + активные сессии немедленно (см. `app.api.v1.team.update_employee`).""" + schema = identity_schema() + db.execute( + text(f"DELETE FROM {schema.sessions_table} WHERE user_id = :user_id"), + {"user_id": user_id}, + ) + db.commit() + + +# --------------------------------------------------------------------------- +# DB-role → RBAC scope (paths/deny) — #2552 dual-mode. +# --------------------------------------------------------------------------- +# +# Роли реестра ('admin'|'manager'|'employee' — CHECK-констрейнт: tradein м.192 для +# tradein_users.role, auth м.004 для auth.users.role; наборы значений совпадают +# намеренно, чтобы код «Меры» переехал на общий реестр без правок в проверках роли) +# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь, +# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же +# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml: +# employee -> клиентский доступ: весь /trade-in/** МИНУС внутренние разделы +# (см. deny ниже — раньше было «ровно как legacy pilot»). +# manager -> employee + /api/v1/team/** (дашборд команды, #2556). +# admin -> полный доступ, как legacy admin. +# +# Почему «Доля в продаже» и «Кэш» в deny у ОБЕИХ клиентских ролей (2026-07-31, +# решение владельца продукта): это внутренние инструменты, а не продукт клиента. +# «Доля в продаже» — аналитика рынка (сколько квартир дома выставлено, срез по +# домам/ЖК), «Кэш» — состояние кэшей и скраперов. Клиентские аккаунты видеть их +# не должны; триггер — аккаунт praktika (DB-роль manager), у которого оба пункта +# висели в топбаре на /trade-in/team. +# +# Почему в deny И страницы (/trade-in/sale-share, /trade-in/cache), И их API +# (/trade-in/api/v1/buildings/**, /trade-in/api/v1/trade-in/cache-stats/**): один +# deny-список гейтит СРАЗУ ТРИ места, потому что все трое сверяются с ним через +# один и тот же матчер — +# 1) пункт меню: Topbar фильтрует NAV_ITEMS по scopePath из /me; +# 2) сама страница: RouteGuard проверяет абсолютный путь из /me; +# 3) серверные ручки: app.core.rbac.rbac_guard (deny проверяется ПЕРВЫМ, +# внешний путь реконструируется как '/trade-in' + path). +# Только страницы = пункт исчез, но прямой URL и API остались открыты; только +# API = мёртвый пункт меню с 403 на каждый фетч. +# +# Почему '/trade-in/api/v1/buildings/**' безопасно закрывать целиком: весь +# роутер app/api/v1/buildings.py обслуживает ТОЛЬКО раздел sale-share +# (/sale-share, /sale-share/summary, /{house_id}/listings). Экран оценки его не +# использует — секция «Продажи в доме» питается estimate-хендлерами +# (useEstimatePlacementHistory / useSalesVsListings), а BuildingListingsDrawer +# импортируется единственной страницей app/sale-share/page.tsx. +# +# NB (границы глоба): '/**' компилируется в '^(?:/.*)?$' — матчит +# сам prefix, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по +# префиксу (см. app.core.rbac._db_glob_match и app.core.auth._glob_to_regex). +# Поэтому '/trade-in/cache/**' не задевает '/trade-in/cache-stats', а +# '/trade-in/api/v1/trade-in/cache-stats/**' — не '/…/cache-statistics'. +# +# Почему у cache-stats ГЛОБ, а не «более точный» '/trade-in/api/v1/trade-in/ +# cache-stats': точный паттерн — это строгое равенство, и его обходит обычный +# трейлинг-слэш (измерено: '…/cache-stats/' → allowed=True). Сегодня от этого +# спасает только Starlette redirect_slashes (307 на путь без слэша → там уже +# 403), т.е. защита держалась бы на роутере, а не на RBAC — достаточно +# выключить redirect_slashes или сменить роутер, и deny тихо перестанет +# работать. Глоб закрывает и сам путь, и слэш, и любые будущие подпути. +# НЕ «уточнять» обратно до точного пути. +# +# NB (ограничение мини-матчера — читать перед копированием паттернов): +# DB_ROLE_PATHS и pilot.deny в auth/roles.yaml — зеркала по СМЫСЛУ, но матчеры +# у них РАЗНЫЕ. app.core.rbac._db_glob_match понимает ТОЛЬКО три формы: +# '/**' | '/**' | точный путь (строгое равенство). +# app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную +# '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из +# roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать — +# без ошибки на импорте и без падения тестов, если на него нет прямого теста. +# Т.е. в DB_ROLE_PATHS допустимы только '/**', '/**' и точный путь; +# одиночная '*' здесь = silent no-op. +DB_ROLE_PATHS: dict[str, tuple[list[str], list[str]]] = { + "employee": ( + ["/trade-in/**", "/trade-in/api/v1/**"], + [ + "/admin/**", + "/api/v1/admin/**", + "/trade-in/api/v1/admin/**", + "/trade-in/sale-share/**", + "/trade-in/cache/**", + "/trade-in/api/v1/buildings/**", + "/trade-in/api/v1/trade-in/cache-stats/**", + ], + ), + "manager": ( + ["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"], + [ + "/admin/**", + "/api/v1/admin/**", + "/trade-in/api/v1/admin/**", + "/trade-in/sale-share/**", + "/trade-in/cache/**", + "/trade-in/api/v1/buildings/**", + "/trade-in/api/v1/trade-in/cache-stats/**", + ], + ), + "admin": (["/**"], []), +} + + +def get_db_role_scope(role: str) -> tuple[list[str], list[str]]: + """Возвращает (allowed_paths, deny_paths) для DB-роли. + + Неизвестная роль (не должно случиться — CHECK-констрейнт на колонке + ограничивает role тремя значениями) -> fail-closed (пустой allow, deny всё). + """ + return DB_ROLE_PATHS.get(role, ([], ["/**"])) diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index d6d68bf2..6e227881 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import logging +from datetime import datetime from typing import Any from curl_cffi.requests import AsyncSession @@ -23,6 +24,11 @@ from app.core.config import settings logger = logging.getLogger(__name__) +# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ +# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания +# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое. +COOKIE_EXPIRY_WARN_DAYS = 5 + # Cookies критичные для Cian auth — фильтр перед сохранением. # Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23). # Старые записи оставлены как fallback (backward compat). @@ -294,6 +300,37 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (#2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM cian_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ), + {"valid_only": valid_only}, + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + def mark_session_invalid(db: Session, account_user_id: int) -> None: """Flag session как expired/invalid (например после 401 во время scrape).""" db.execute( diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index a14626e5..8a5b9c9f 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -343,6 +343,10 @@ async def suggest_addresses( жёсткий фильтр (не boost) на уровне указанного admin-поля — доп. параметров не требуется. По умолчанию не задан — поведение (и body запроса) для существующих вызовов не меняется. + ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит + БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл"). + Передашь «Свердловская область» — совпадений не будет, и запрос + вернёт ПУСТО без всякой ошибки (hard-filter, не boost). Returns: list[DadataSuggestion] — пустой список если: diff --git a/tradein-mvp/backend/app/services/domclick_session.py b/tradein-mvp/backend/app/services/domclick_session.py index 177ee772..85fdef97 100644 --- a/tradein-mvp/backend/app/services/domclick_session.py +++ b/tradein-mvp/backend/app/services/domclick_session.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +from datetime import datetime from sqlalchemy import text from sqlalchemy.orm import Session @@ -25,6 +26,13 @@ from app.core.config import settings logger = logging.getLogger(__name__) +# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана). +# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: +# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03: +# куки протухли, единственным следом был WARNING в docker-логе, который к тому же +# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое. +COOKIE_EXPIRY_WARN_DAYS = 5 + # Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением. # Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной # test-аккаунт сессии (Sber ID login), 2026-07-04. @@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None: return cookies +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM domclick_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ), + {"valid_only": valid_only}, + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + def mark_session_invalid(db: Session, account_cas_id: int) -> None: """Flag session как expired/invalid (например после блока во время scrape).""" db.execute( diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 07613f48..eeced17f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -48,6 +48,7 @@ from scraper_kit.providers.cian.valuation import ( estimate_via_cian_valuation, ) from scraper_kit.providers.yandex.valuation import ( + ValuationHouseMeta, YandexValuationResult, YandexValuationScraper, ) @@ -80,6 +81,7 @@ from app.services.house_metadata import get_house_metadata from app.services.matching.houses import match_house_readonly, match_or_create_house from app.services.scraper_adapters import RealScraperConfig from app.services.scraper_settings import get_scraper_delay +from app.tasks.asking_to_sold_ratio import area_bucket logger = logging.getLogger(__name__) @@ -165,6 +167,24 @@ def _estimate_consent_persist_fields( DKP_CORRIDOR_CITY_WIDE_MIN_N = 3 DEALS_HEADLINE_FALLBACK_MIN_N = 3 +# #oblast-E (money-path sufficiency gate, live-audit 2026-08-02): минимум +# LIVE-объявлений, из которых можно честно построить headline-медиану по +# рынку. Ниже порога median() по 1-4 случайным лотам не отражает рынок — +# live repro на проде: Серов 2к/45м², n=3 листинга → 42 391 ₽/м² (−36% vs +# городской ДКП-коридор 54 126 ₽/м², которых сама эта улица не показывала +# из-за тонкой street-выборки), а соседняя улица того же города с другими +# 1-2 случайными лотами даёт разброс до ×1.66. Ниже порога +# _price_from_inputs ОБНУЛЯЕТ радиусную популяцию (listings_clean=[]) — +# 100%-переиспользует уже существующий и покрытый тестами путь «листингов +# нет» (same-building anchor / #oblast-D deals-headline-fallback / +# insufficient_data ниже), а не изобретает новую ветку. Значение 5 выбрано +# по live-данным (n=3 уже недостаточно; n=5 — тот же порог, что +# MIN_ANALOGS_TIER_0 использует для "хватает на строгий когортный тир" — +# согласованная планка "достаточно, чтобы не быть шумом одного-двух лотов"). +# НЕ трогает подбор аналогов/тиры/радиусы — только решение, доверять ли +# ИТОГОВОЙ выборке как headline-источнику. +HEADLINE_LISTINGS_MIN_N = 5 + # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). @@ -264,6 +284,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]: return bands +# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02). +# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась +# после революции — год раньше почти гарантированно ошибка источника +# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания +# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata +# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница +# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по +# ДДУ известен заранее, но не более чем на несколько лет вперёд). +# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе — +# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit +# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше +# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и +# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка +# выхода за диапазон под видом уверенной −25% поправки. «Не знаем год» — +# честный сигнал, который просто отключает year-term фактора (нейтрален). +MIN_PLAUSIBLE_BUILD_YEAR = 1917 +MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки + + +def _sanitize_build_year( + year: int | None, *, house_id: int | None = None, address: str | None = None +) -> int | None: + """Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен». + + Валидный диапазон — [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне + диапазона логируется на WARNING (с идентификатором дома — house_id либо + адрес) и заменяется на None, а не клампится к границе: клампинг превращает + заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из + payload — ge=1800 в схеме пропускает подобные значения) в уверенный вход + для хедонической поправки (_price_from_inputs), хотя физического смысла + у результата нет. + """ + if year is None: + return None + max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD + if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year: + logger.warning( + "estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — " + "valid range [%s, %s]", + year, + house_id, + address, + MIN_PLAUSIBLE_BUILD_YEAR, + max_year, + ) + return None + return year + + # Когорта по году постройки — типизация массовой застройки РФ. # Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24). # Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается. @@ -377,11 +446,22 @@ _asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {} def _get_asking_sold_ratio( db: Session, rooms: int | None, + area_m2: float | None = None, anchor_ppm2: float | None = None, ) -> tuple[float | None, str | None]: - """Возвращает (ratio, basis) asking→sold для бакета комнат. + """Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры. - bucket = min(max(rooms or 0, 0), 4). + bucket = area_bucket(area_m2) при area_m2 > 0, иначе min(max(rooms or 0, 0), 4) + (rooms-фолбэк — только когда площадь неизвестна). + + #2620-2 (deep-review, вторая половина #2620): расчёт ratio (asking_to_sold_ratio.py + ask_side) теперь ключуется по area-бакету (rooms_bucket в asking_to_sold_ratios — + это на самом деле area-бакет, легаси-имя колонки), но ДО этой правки применение + здесь читало rooms_bucket по РЕАЛЬНЫМ комнатам клиента — тот же mismatch, что чинили + в расчёте, просто переехавший в применение. Прод-замер ревьюера (2026-08): 310/1038 + (29.9%) исторических клиентских запросов легли бы в другой ratio-бакет при + rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в + asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh). Запрос к asking_to_sold_ratios (migration 080): per-rooms строка (WHERE rooms_bucket = bucket AND district = '') → fallback на global -1 @@ -395,7 +475,7 @@ def _get_asking_sold_ratio( Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful). Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S. """ - bucket = min(max(rooms or 0, 0), 4) + bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4) cached = _asking_sold_ratio_cache.get(bucket) if cached is not None: @@ -811,10 +891,15 @@ def _save_yandex_history_items( (address|publish_date|area|floor|prices) hash. Batch semantics: single try/except; on any failure the batch rolls back. - """ - if not result.history_items: - return 0 + #2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по + пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но + без единого объявления в истории, до записи панорамы не доходила — на проде это + 1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена + переноса: match_or_create_house теперь вызывается и для таких страниц (может + СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на + остальных 90% — новых сущностей класс не появляется, появляется недостающая доля. + """ # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # — stable across re-runs, distinguishes pages for different addresses. address_seed = (result.address or "").strip().lower() @@ -850,6 +935,12 @@ def _save_yandex_history_items( result.address, ) + # Наблюдение о доме не зависит от того, есть ли на странице история объявлений. + _save_yandex_house_panorama(db, house_id, result.house) + + if not result.history_items: + return 0 + rows = [] skipped_area = 0 for item in result.history_items: @@ -931,6 +1022,60 @@ def _save_yandex_history_items( return 0 +# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в +# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но +# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь — +# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету. +# +# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text +# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн, +# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет». +# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность +# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL. +def _save_yandex_house_panorama( + db: Session, + house_id: int | None, + meta: ValuationHouseMeta, +) -> None: + """Пишет houses.has_panorama по разобранной мете yandex_valuation. + + No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт + выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за + справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает + только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история. + """ + if house_id is None: + return + if meta.year_built is None and meta.total_floors is None: + logger.debug( + "yandex_valuation: has_panorama не пишем для house_id=%s — " + "страница не подтверждена (нет ни года, ни этажности)", + house_id, + ) + return + try: + with db.begin_nested(): + db.execute( + text( + """ + UPDATE houses + SET has_panorama = CAST(:panorama AS boolean) + WHERE id = CAST(:hid AS bigint) + AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean) + """ + ), + {"hid": house_id, "panorama": meta.has_panorama}, + ) + db.commit() + except Exception as e: + logger.warning( + "yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s", + house_id, + e, + ) + db.rollback() + + # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── @@ -1686,6 +1831,20 @@ def _normalize_building_key( (корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д). - street_core прогоняется через _STREET_ALIAS_MAP (ткачева→ткачей). + #2581: город/р-н НАМЕРЕННО дропается (не возвращается в ключе) — это + единственное, что делает «Ткачёва 13»-вариант с городом и без города давать + один ключ (см. test_normalize_tkachei13_all_db_variants_same_key). Городской + токен как 4-й элемент ключа НЕ добавлен: (а) часть источников (Avito + anonymous-адреса) вообще не несёт городской токен в тексте — ключ с + обязательным городом сломал бы их матчинг; (б) написание города варьируется + (ЕКБ/Екатеринбург/г. Екатеринбург) — ещё один normalization-слой, дающий + те же false-negative риски, которые уже решает `_CITY_TOKENS`-дропинг. + Кросс-городская коллизия (одноимённая улица+дом в разных городах области) + закрыта на SQL-уровне через ST_DWithin от subject-координат — см. Tier A + в `_fetch_anchor_comps` (#2581) и Tier S в `_fetch_analogs` (#oblast-D, + f9ae6f0c) — геопредикат надёжнее строкового city-токена и не ломает то, + что уже нормализуется здесь. + Returns (street_core, base_no, letter) — любой элемент None если не извлёкся. Best-effort: при пустом адресе → (None, None, None). """ @@ -1786,6 +1945,18 @@ def _anchor_comp_from_row(r: Any) -> dict[str, Any]: } +# #2581: Tier A ("same building") ST_DWithin safety-radius. Reuses the SAME +# city-scale DEFAULT_RADIUS_M already used by Tier S's mirrored geo-bound fix +# (f9ae6f0c, #oblast-D). The address-string match (_normalize_building_key + +# _house_boundary_regex) already establishes "same street + house number" — +# this radius only needs to reject GENUINELY cross-city collisions (e.g. +# «улица Ленина» exists in both Екатеринбург AND Серов/Нижний Тагил, 150+ km +# apart) — it does not need to be building-tight like Tier C's 500m +# micro-radius (that tier's precision comes from proximity alone, without a +# street/house string match to lean on). +ANCHOR_TIER_A_RADIUS_M = DEFAULT_RADIUS_M + + def _fetch_anchor_comps( db: Session, *, @@ -1798,9 +1969,19 @@ def _fetch_anchor_comps( ) -> tuple[list[dict[str, Any]], str | None]: """Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps. - Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть). - RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk — - один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797). + Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть) + + ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого + SQL не имел ГЕО-предиката вовсе, и одноимённая улица+дом в ДРУГОМ городе + (область — 368 городов, «Ленина»/«Мира»/... повторяются) молча матчила + ЕКБ-листинги для областного subject'а — ~40 191 из ~40 200 активных + листингов ЕКБ, distance неизвестна без фильтра). RELAXED rooms (без + фильтра), БЕЗ area±15%. Не группируем по house_id_fk — один дом дробится + на несколько fk (Хохрякова 48 = 7085/9878/12797); ST_DWithin — тот же + компромисс, что и Tier S ниже (см. _fetch_analogs), не house_id_fk. + lat/lon subject'а обязательны (гейт как у Tier C) — без них геопредикат + невозможен, и Tier A целиком пропускается (в проде geo ВСЕГДА есть — + estimate_quality возвращает _empty_estimate раньше при неудачном + geocode, см. `if geo is None`). Tier C — micro-radius ≤500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy вторичка + rooms match + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url ненадёжны.) @@ -1816,7 +1997,7 @@ def _fetch_anchor_comps( # ── Tier A: same building ──────────────────────────────────────────────── street, base_no, letter = _normalize_building_key(address) - if street and base_no is not None: + if street and base_no is not None and lat is not None and lon is not None: # ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param, # оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с # Tier S radius-fallback ниже, см. _fetch_analogs). @@ -1835,11 +2016,20 @@ def _fetch_anchor_comps( AND price_per_m2 > 0 AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re + AND geom IS NOT NULL + AND ST_DWithin( + geom::geography, + ST_MakePoint(:lon, :lat)::geography, + :radius + ) """ ), { "street_like": "%" + street + "%", "house_re": house_re, + "lon": lon, + "lat": lat, + "radius": ANCHOR_TIER_A_RADIUS_M, }, ) .mappings() @@ -1964,12 +2154,17 @@ def _band_haircut(anchor_ppm2: float) -> float: LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) — EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет city-параметра ни здесь, ни у единственного вызывающего - `_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет - (same-building anchor pool для oblast сейчас не формируется — anchor_ppm2 сюда - просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как - только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги - нужно пересмотреть/сделать per-city — не оставлять ЕКБ-калибровку по умолчанию - для другого рынка. No behavior change here (doc-only). + `_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ — + same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до + #2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо + молча тянул ЕКБ-листинги по одноимённой улице/дому, либо — для действительно + уникальных названий — честно матчил местные листинги, если они были). После + #2581 (ST_DWithin(ANCHOR_TIER_A_RADIUS_M) на Tier A) anchor_ppm2 ДЛЯ ОБЛАСТИ + доходит сюда легитимно (местные комплы того же дома в Серове/Тагиле/etc, если + они есть в БД), но всё ещё через ЕКБ-калиброванный haircut — эти пороги + по-прежнему стоит пересмотреть/сделать per-city, не оставлять ЕКБ-калибровку + по умолчанию для другого рынка. No behavior change here (doc-only, кроме + исправления ложной посылки). """ if anchor_ppm2 >= 350_000: return 0.04 @@ -2367,6 +2562,12 @@ class PricingResult: # headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV # радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных). cv: float | None = None + # #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N + # (headline suppressed, listings_clean deliberately left intact — see gate + # comment above). Caller uses this to also keep the thin listings out of the + # display `analogs` cards when no anchor overrides the headline. 0 = either + # sufficient listings were used, or genuinely zero were found. + listings_headline_thin_n: int = 0 def _price_from_inputs( @@ -2445,10 +2646,45 @@ def _price_from_inputs( n_analogs = 0 cv = None - # 4b. Repair coefficient + # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above). + # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust + # as a market median — suppress the AGGREGATE (median/range/n_analogs/cv) + # exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/ + # insufficient_data chain below all take the already-honest zero-analogs + # path automatically (no new branches there). `listings_clean` itself is + # deliberately LEFT INTACT (not cleared) — the same-building anchor's own + # ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell + # "genuinely zero nearby listings" from "some nearby listings, just too few + # to trust as THIS estimate's headline" — those are different confidence + # signals and clearing the list here would conflate them. The caller + # (estimate_quality) uses `listings_headline_thin_n` on the returned + # PricingResult to also keep suppressed listings out of the display + # `analogs` cards when no anchor overrides the headline (n_analogs + # invariant: cards shown ⊆ what n_analogs counts). + listings_headline_thin_n = 0 + if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N: + listings_headline_thin_n = n_analogs + logger.info( + "headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing " + "listings-derived median (falling back to anchor/deals/insufficient_data)", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + ) + median_ppm2 = 0.0 + q1_ppm2 = 0.0 + q3_ppm2 = 0.0 + median_price = 0 + range_low = 0 + range_high = 0 + n_analogs = 0 + cv = None + + # 4b. Repair coefficient — skipped when the headline was thin-suppressed + # above (median_price is already 0; applying a coefficient would leave it + # 0 but still emit a misleading "adjusted for repair state" note). repair_coef = _repair_coefficient(repair_state) repair_note = "" - if listings_clean and repair_coef != 1.0: + if listings_clean and not listings_headline_thin_n and repair_coef != 1.0: median_price = int(median_price * repair_coef) range_low = int(range_low * repair_coef) range_high = int(range_high * repair_coef) @@ -2489,6 +2725,20 @@ def _price_from_inputs( area_widened, listings=listings_clean, ) + # #oblast-E: honest override — _compute_confidence's generic "не найдено + # аналогов" is FALSE here (we DID find listings_headline_thin_n of them, + # just too few to trust). Stays the final explanation unless a later block + # (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest + # reasoning — both of those already check truthy `explanation` and either + # replace it (anchor) or append a construction-method clause that reads + # this same thin-count (deals-fallback), so no contradiction either way. + if listings_headline_thin_n: + confidence = "low" + explanation = ( + f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., " + f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по " + "такой маленькой выборке слишком чувствительна к случайным лотам." + ) # Tier note — информируем пользователя о качестве house-match tier_note = "" @@ -3041,9 +3291,20 @@ def _price_from_inputs( # generic ghost-anchor). No repair_state adjustment: the deal corridor # mixes conditions across sold units — unlike the listings comp pool, # there is no per-unit signal to correct against. + # + # #oblast-E: guards on `anchor is None` (the actual computed anchor dict), + # NOT `anchor_tier is None`. Found via live backtest-fixture regen: when + # `_compute_same_building_anchor` rejects a candidate outright (e.g. its + # own MAD-clip drops comps below estimate_sb_min_comps), it returns None + # WITHOUT the caller resetting `anchor_tier` back to None (it stays + # whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired, + # but the stale tier flag falsely reads as "anchor claimed the headline" + # and blocked this fallback even with a large, valid ДКП corridor + # available (observed: 677 deals for one fixture case). `anchor is None` + # is the ground truth of whether the anchor actually produced a headline. if ( median_ppm2 <= 0 - and anchor_tier is None + and anchor is None and dkp_raw is not None and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N and dkp_raw.get("median_ppm2", 0) > 0 @@ -3055,16 +3316,27 @@ def _price_from_inputs( n_analogs = 0 confidence = "low" cv = None + # #oblast-E: differentiate "genuinely zero listings" (unchanged wording) + # from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" — + # the latter must NOT claim "рядом нет объявлений" (false, contradicts the + # thin-sufficiency explanation already set above this block). + no_listings_clause = ( + f" Из {listings_headline_thin_n} найденных объявлений недостаточно для " + "надёжной медианы —" + if listings_headline_thin_n + else " Рядом нет актуальных объявлений —" + ) explanation = (explanation or "") + ( - " Рядом нет актуальных объявлений — оценка построена по реальным " + f"{no_listings_clause} оценка построена по реальным " f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.)," " точность ориентировочная." ) logger.info( "deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline" - " (listings=0, anchor=None)", + " (listings=0 [thin_suppressed=%d], anchor=None)", int(median_ppm2), dkp_raw["count"], + listings_headline_thin_n, ) # ── #652: ДКП-коридор реальных сделок (advisory) ───────────────────────── @@ -3196,6 +3468,7 @@ def _price_from_inputs( sources_used_pre=sources_used_pre, listings_clean=listings_clean, cv=cv, + listings_headline_thin_n=listings_headline_thin_n, ) @@ -3245,12 +3518,13 @@ async def estimate_quality( detail="consent required for anonymous estimate request", ) - # 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack + # 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack # multiple network round-trips + 1s Nominatim rate-limit sleeps). geo: GeocodeResult | None = None # Variant A: trust client-provided coords (resolved by autocomplete/map) when present # and inside the oblast bbox — skips the geocode() chain that fails on DaData-format - # addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally. + # addresses (#2593: Yandex Geocoder, the previous fallback for those, removed). + # Out-of-bbox / partial → ignore, geocode normally. # (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to # geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also # get this perf fast-path instead of always paying the geocode() round-trip. Perf-only, @@ -3274,8 +3548,12 @@ async def estimate_quality( payload.lon, ) if geo is None and payload.address: + # #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не + # подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально: + # фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous + # честно сигнализирует об этом ниже. geo = await _with_budget( - geocode(payload.address, db), + geocode(payload.address, db, city_hint=payload.city_hint), settings.estimate_geocode_budget_s, label="geocode", ) @@ -3380,6 +3658,16 @@ async def estimate_quality( if target_house_type is None: target_house_type = house_meta.house_type + # 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме, + # либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на + # «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже), + # _fetch_analogs house-match scoring и хедонический фактор + # (_price_from_inputs, #2002). Единая точка входа — все три места ниже + # используют этот же target_year. + target_year = _sanitize_build_year( + target_year, house_id=target_house_id, address=payload.address + ) + # 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter): # 0) 1km + ±15% area + cohort match (year_built — если задан) # a) 1km + ±15% area (без cohort — drop fallback) @@ -3392,6 +3680,13 @@ async def estimate_quality( # радиус (он же — максимум, без авто-расширения за пределы выбранного). base_radius_m = payload.radius_m or DEFAULT_RADIUS_M fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M + # #2632: фактический радиус, по которому реально отобраны listings-аналоги + # (в отличие от payload.radius_m — выбор пользователя в дропдауне). Стартует + # с base_radius_m, переключается на fallback_radius_m в тех же ветках, что + # выставляют fallback_used ниже (см. _compute_confidence "расширили радиус" + # note) — держим оба сигнала консистентными по построению. Прокидывается в + # AggregatedEstimate.search_radius_m для карты (ParamsPanel circle, #2632). + search_radius_m = base_radius_m cohort_range = _target_cohort_range(target_year) if cohort_range is not None: @@ -3456,6 +3751,7 @@ async def estimate_quality( listings = listings_wide fallback_used = True analog_tier = analog_tier_wide + search_radius_m = fallback_radius_m # Tier C: если даже на 2км мало — расширяем area tolerance до ±25% # (актуально для отдалённых районов / новостроек с нестандартной планировкой) @@ -3480,6 +3776,7 @@ async def estimate_quality( fallback_used = True area_widened = True analog_tier = analog_tier_wa + search_radius_m = fallback_radius_m # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы @@ -3627,7 +3924,8 @@ async def estimate_quality( def _ratio_resolver( appm2: float | None, ) -> tuple[float | None, str | None]: - return _get_asking_sold_ratio(db, payload.rooms, anchor_ppm2=appm2) + # #2620-2: area-bucket key (payload.rooms — фолбэк только без площади). + return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2) def _qi_lookup(q: str) -> tuple[float, int] | None: return _lookup_quarter_index( @@ -3692,6 +3990,7 @@ async def estimate_quality( ratio_basis = pr.ratio_basis listings_clean = pr.listings_clean cv = pr.cv + listings_headline_thin_n = pr.listings_headline_thin_n # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков @@ -3727,6 +4026,14 @@ async def estimate_quality( # иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому # набору (или = None при пустом listings_clean, хотя у комплов данные есть). metadata_lots = display_pool + elif listings_headline_thin_n: + # #oblast-E: headline was suppressed (thin radius sample, no anchor to + # take over) — do NOT surface those same listings as display cards + # either, else `analogs` would show N cards while n_analogs==0 (broken + # invariant, same dishonesty this gate exists to remove). Degrades to + # the exact same empty-display state as "genuinely zero listings". + analogs_lots = [] + metadata_lots = [] else: # display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что # дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs. @@ -3984,6 +4291,7 @@ async def estimate_quality( target_address=geo.full_address, target_lat=geo.lat, target_lon=geo.lon, + target_city_ambiguous=geo.city_ambiguous, sources_used=sources_used, data_freshness_minutes=freshness_min, last_scraped_at=last_scraped_at, @@ -4033,6 +4341,11 @@ async def estimate_quality( metro_nearest=(dadata.metro if dadata and dadata.metro else []), address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None), analog_tier=api_analog_tier, # type: ignore[arg-type] + # #2632: фактический радиус отбора listings-аналогов (см. search_radius_m + # def выше) — может отличаться от payload.radius_m (выбор пользователя), + # когда сервер сам расширил поиск. None только у _empty_estimate (поиск + # аналогов вообще не выполнялся). + search_radius_m=search_radius_m, premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, premium_building_class=premium_building_class, @@ -5621,11 +5934,6 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: return street, house -def _extract_street_token(addr: str | None) -> str: - """Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house.""" - return _parse_street_house(addr)[0] - - def _lot_dedup_components( lot: dict[str, Any], *, @@ -5663,15 +5971,13 @@ def _lot_dedup_components( return cad_s, house, cad_key, street_key -def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: - """Первичный физический ключ (building, floor, area_bucket, price_bucket). - - building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет - площади/цены или не из чего построить building. Сохраняет 4-кортежную форму - (canonical-ключ; union-find в _dedup_cross_source использует оба композита). - """ - _cad_s, _house, cad_key, street_key = _lot_dedup_components(lot) - return cad_key or street_key +# #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки +# над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу +# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ = +# кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит +# ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера +# домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые +# функции (tests/test_estimator_dedup_cross_source_2087.py). def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1bf0817f..f3cdb12e 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -2,7 +2,7 @@ Стратегия: - Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней -- Cache miss → Yandex Geocoder (если есть key) → fallback Nominatim +- Cache miss → локальные ЕКБ-тиры (геопортал/кадастр) → fallback Nominatim - Результат сохраняется в кэш для последующих вызовов Используется в: @@ -16,7 +16,7 @@ from __future__ import annotations import asyncio import logging import re -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal import httpx @@ -36,8 +36,14 @@ class GeocodeResult: lat: float lon: float full_address: str - provider: Literal["nominatim", "yandex", "cache"] + provider: Literal["nominatim", "cache"] confidence: Literal["exact", "approximate", "locality"] = "approximate" + # #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни + # через `city_hint`) — т.е. итоговый город результата определил провайдер + # (или локальный ЕКБ-тир), а не вызывающий код. Не эвристика на «правильность» + # результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог + # понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`. + city_ambiguous: bool = False # ── EKB bounding boxes ─────────────────────────────────────────────────────── @@ -82,9 +88,10 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool: # bbox = (lat_min, lat_max, lon_min, lon_max). OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6) -# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же -# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim -# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat). +# Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю +# область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn — +# legacy-поля того же словаря (были общим форматом с удалённым #2593 Yandex +# Geocoder-тиром), не используются Nominatim-кодом напрямую. OBLAST66_VIEWBOX = { "viewbox": "56.7,62.2,66.6,55.8", "ll": "61.65,59.0", @@ -98,10 +105,9 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool: # Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30 -# городов), покрывает основные региональные центры. Используется чтобы: -# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал -# другой город региона (геокодер forward/suggest prefix-логика); -# (b) как known-locality set в _yandex_reverse при перестановке "улица, город". +# городов), покрывает основные региональные центры. Используется чтобы не +# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой +# город региона (геокодер forward/suggest prefix-логика). SVERDLOVSK_OBLAST_CITIES = frozenset( { "екатеринбург", @@ -150,7 +156,50 @@ SVERDLOVSK_OBLAST_CITIES = frozenset( # без district-префикса ложно ушёл бы в non-EKB gate. } ) -SVERDLOVSK_OBLAST_REGION = "Свердловская область" + + +def known_city_hint(value: str | None) -> str | None: + """`value` как city_hint, если это узнаваемое имя города региона 66, иначе None. + + Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()` + (#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте + распределения лежит мусор («Бессонова», «Бердюгина», «Билейский + рыбопитомник» — улицы/урочища, попавшие в поле города). Мусорный хинт хуже + отсутствия хинта: любой не-ЕКБ `city_hint` жёстко закрывает EKB-локальные + тиры (`_ekb_local_tiers_allowed`) И подставляется префиксом в запрос + провайдеру (`_resolve_city_for_geocode`) — «Бессонова, Бессонова 10» + провайдер не резолвит вовсе. + + Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские + гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город, + отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый + (`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не + резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и + гейт хинта не создаёт новой связности. + + Цена решения (осознанная): легитимный, но не перечисленный населённый пункт + («Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по + тексту адреса» — то же, что было до прошивки хинта, без регрессии. + + Пользовательский ввод (`/geocode/lookup`, `/geocode/suggest`, + `TradeInEstimateInput.city_hint`) сюда НЕ заворачиваем: там город назвал + человек, и молча его игнорировать нельзя — для произвольной строки + fail-closed отрабатывает `_ekb_local_tiers_allowed` (#2580/#2589). + """ + if not value: + return None + return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None + + +# Значение для DaData-констрейнта `locations: [{"region": ...}]`. +# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`, +# `region_type="обл"` (тип лежит в отдельных полях `region_type` / +# `region_with_type`). `locations` сравнивает именно с `region`, поэтому +# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал +# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов, +# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id). +# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`. +SVERDLOVSK_OBLAST_REGION = "Свердловская" # Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город # «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский @@ -191,6 +240,36 @@ def _has_oblast_marker(text_lower: str) -> bool: return False +def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]: + """Определяет, какой город подставлять в запрос внешнему провайдеру + (Nominatim), когда сам текст адреса города не называет. + + Приоритет: + 1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`) + → город уже указан пользователем в тексте адреса, ничего подставлять не + нужно. Возвращает (None, True). + 2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный + город из предыдущего шага UI) → подставляем его. Возвращает (city, True). + 3. Ни то, ни другое → раньше (#2576) здесь молча подставлялся "Екатеринбург" + — для жителей других городов области это давало уверенно неверную цену + («Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина, + обе улицы называются одинаково). Теперь НЕ подставляем никакой город — + провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без + привязки к конкретному городу. Возвращает + (None, False) — второй элемент False сигнализирует, что город + пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`). + + Returns: + (city_or_none, city_specified_by_user). + """ + if _has_oblast_marker(address.lower()): + return None, True + hint = (city_hint or "").strip() + if hint: + return hint, True + return None, False + + # Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only # локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого # города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}. @@ -231,6 +310,117 @@ def _names_non_ekb_city(address: str) -> bool: return False +# ── Fail-closed EKB-local-tiers gate (#2582) ───────────────────────────────── +# `_names_non_ekb_city` выше ловит только 37 ПЕРЕЧИСЛЕННЫХ городов области — +# любой другой топоним (другой регион РФ целиком, ИЛИ областной город вне +# списка: Реж/Арамиль/Рефтинский/Лёвиха) молча проходил как «не назван», и +# EKB-only локальные тиры (geoportal/cad_buildings) снапали улицу+дом в ЕКБ по +# коллизии (см. живые случаи в geocode_cache: «республика крым, ялта, ...» → +# координаты ЕКБ, «челябинская обл., трёхгорный, ...» → координаты ЕКБ). +# +# Вместо перечисления (сама причина бага — список никогда не покроет все +# города РФ) используем общий структурный признак «в адресе назван +# город/регион»: маркер-слово, которое НИКОГДА не относится к улице/дому +# («обл.», «край», «республика», «г.», «пос.»). +# +# Два разных класса маркеров — разная форма regex: +# 1. Маркеры БЕЗ обязательной точки после (обл/область/край/республика/респ/ +# г/пос/посёлок/поселок) — сохраняют старую форму `\bслово\.?\b`. Точка +# опциональна и есть трейлинг `\b`, который для этих слов срабатывает и +# без точки (следующий символ — пробел/запятая, не-словесный). +# 2. Маркеры-ОДНОБУКВЕННЫЕ сокращения, где точка ОБЯЗАТЕЛЬНА для отличия от +# других смыслов той же буквы («с» — типографский мусор/что угодно, «д» — +# дом-маркер `_HOUSE_MARKER`) — «с.», «д.», «дер.», «пгт.», «ст.». Тут +# хвостовой `\b` В ПРИНЦИПЕ не может сработать после точки перед пробелом +# (оба не-словесные — на стыке `.` и ` ` нет границы \w/не-\w) — было +# смерто-рождённой формой, вылавливавшей 0 из 104 прод-адресов «с. <село>» +# (deep-review PR #2589, H1). Вместо `\b` в конце — lookahead на кириллицу +# сразу после точки (с опциональными пробелами): `\.\s*(?=[а-яё])`. +# Это и различает «д. 5» (дом-маркер, номер, НЕ топоним — после точки +# цифра, lookahead не матчит) от «д. Кашино» (деревня — после точки буква, +# матчит) БЕЗ явного исключения "д." из списка: раньше "д." исключали +# целиком, потому что не было способа отличить дом от деревни — lookahead +# даёт этот способ, поэтому "д."/"дер." теперь можно включить безопасно. +_LOCALITY_MARKER_RE = re.compile( + r"\b(?:обл\.?|область|край|республика|респ\.?|г|пос\.?|посёлок|поселок)\b" + r"|\b(?:с|д|дер|пгт|ст)\.\s*(?=[а-яё])" +) + + +def _names_unrecognized_locality(address: str) -> bool: + """True если адрес называет топоним, но это не Екатеринбург и не город из + известного списка `SVERDLOVSK_OBLAST_CITIES` (#2582). + + Отличает «город не назван» (bare «Ленина 1» — так приходит основной + трафик формы оценки; geoportal резолвит его как ЕКБ и это оправданный + дефолт) от «назван НЕЗНАКОМЫЙ город» (Ялта/Трёхгорный/Лёвиха, «с. Кашино», + «д. Родина» — структурный маркер топонима есть, но это не ЕКБ и не + известный город региона 66) — только во втором случае EKB-only локальные + тиры нельзя пускать. + + Известная дыра (сознательно, не «баг» — закрывать architectural-решением + отдельно, не перечислением городов): голый «Реж, Ленина 5» / «Арамиль, + Мира 1» БЕЗ структурного маркера («обл.»/«г.»/«с.» и т.п.) и без города + из списка `SVERDLOVSK_OBLAST_CITIES` не ловится — падает в «город не + назван» и уходит в ЕКБ-дефолт. Все живые прод-подмены (geocode_cache) + были с маркером/региональным префиксом — см. тесты `test_ekb_local_tiers_allowed` + (изолирующий кейс на голое «реж, ленина 5» зафиксирован как известная дыра). + """ + normalized = " ".join(address.lower().split()) + if _EKATERINBURG_RE.search(normalized): + return False # явный ЕКБ — приоритет, см. _names_non_ekb_city + return bool(_LOCALITY_MARKER_RE.search(normalized)) + + +def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool: + """Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582). + + ЕКБ-only локальные реестры (geoportal/cad_buildings) используются ТОЛЬКО + когда Екатеринбург подтверждён явно (текстом адреса или `city_hint`), или + нигде не назван никакой город/регион. Гейт закрывается, если сработал + ЛЮБОЙ из трёх независимых сигналов (OR, не приоритетная цепочка): + + - известный город региона 66 в ТЕКСТЕ адреса (`_names_non_ekb_city`, без + изменений, #11); + - `city_hint` ЗАДАН и НЕ является Екатеринбургом (`hint_blocks` ниже) — + #2580 (C1) + #2589 (deep-review M2): `city_hint` — явный СТРУКТУРНЫЙ + вход от caller'а (не свободный текст адреса), поэтому для него + fail-closed форма ОБРАТНАЯ по сравнению с текстом адреса: «не + Екатеринбург → закрыто», а не «есть в списке 37 → закрыто». Список + 37 годился бы, только если единственный риск — известные города; но + `city_hint` принимает произвольную строку без валидации (в т.ч. через + API напрямую, минуя 6-городный selector UI) — «Реж»/любой седьмой + город, добавленный в реестр, до сих пор не в списке 37 → список-based + проверка держала гейт открытым, geoportal отдавал точный ЕКБ-дом, а + `city_ambiguous=False` (хинт ведь БЫЛ) делало это уверенно-неверным — + худший случай (без hint был бы честный `city_ambiguous=True`); + - незнакомый топоним в ТЕКСТЕ адреса — структурный маркер + (`_names_unrecognized_locality`, #2582: «обл./край/республика/г./пос.» + и однобуквенные «с./д./дер./пгт./ст.» с lookahead на кириллицу после + точки) — покрывает города вне списка 37 С маркером (Лёвиха, «с. + Новоалексеевское») и ДРУГИЕ РЕГИОНЫ РФ целиком (Ялта, Трёхгорный), не + требуя их перечислять. Голый город без маркера и вне списка (Реж без + «обл.») — известная дыра, см. `_names_unrecognized_locality`. + + До #2582 решение по третьему пункту принималось от противного: «нет в + списке 37 городов → считаем Екатеринбургом» — молча ломало любой адрес + вне списка (см. docstring `_names_unrecognized_locality`). + + Note: т.к. это OR трёх независимых сигналов (не приоритетная цепочка), + `city_hint`, противоречащий явному "Екатеринбург" в тексте адреса + (`_names_non_ekb_city(address)` для такого текста вернёт False и не + закроет гейт сам по себе — Екатеринбург в тексте не мешает), тем не менее + ЗАКРОЕТ гейт через `hint_blocks` — тот же trade-off, что уже был одобрен + в #2580 (`use_local_ekb`), сюда не переписываем. Нормальный кейс — caller + не передаёт противоречащие address и city_hint одновременно. + """ + hint = " ".join((city_hint or "").lower().split()) + hint_blocks = bool(hint) and not _EKATERINBURG_RE.search(hint) + return not ( + _names_non_ekb_city(address) or hint_blocks or _names_unrecognized_locality(address) + ) + + # ── Address normalisation ─────────────────────────────────────────────────── def normalize_address(address: str) -> str: """Нормализация для cache lookup: lowercase + trim + collapse whitespace. @@ -240,6 +430,29 @@ def normalize_address(address: str) -> str: return " ".join(address.lower().strip().split()) +def _cache_key(address_norm: str, city_hint: str | None) -> str: + """Ключ `geocode_cache.address_normalized` — адрес, дополненный городом, + если он известен вызывающему коду. + + #2576: раньше ключ был просто нормализованный адрес — одинаковый для + «Ленина, 1» независимо от того, кто спрашивает (ЕКБ или Нижний Тагил). + Т.к. геокодер раньше молча предполагал ЕКБ, оба города писали/читали ОДНУ + и ту же строку кэша → взаимная порча (первый запрос «застолбил» город для + второго). С `city_hint` разные города для одного текста адреса больше не + делят один ключ. + + БЕЗ `city_hint` формат ключа не меняется (backward-compatible с уже + накопленным кэшем) — коллизия между городами для запросов без hint'а + остаётся возможной (структурно неизбежно, пока вызывающий код не начнёт + передавать city_hint повсеместно), но `city_ambiguous` на результате + честно сигнализирует об этом вызывающему. + """ + city_norm = " ".join((city_hint or "").lower().strip().split()) + if not city_norm: + return address_norm + return f"{address_norm}|city={city_norm}" + + # Согласные, которые часто пишут с одной буквой вместо двух (RU typos). _DOUBLE_CONSONANTS = "лнмссккттпп" @@ -386,12 +599,21 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _nominatim_lookup(address: str) -> GeocodeResult | None: +async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None: """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. Бан-policy: User-Agent с email обязателен. Tier 1: bounded область (region 66) на оригинальный адрес. Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга). + + #2580 (C): city_hint, если известен, подставляется в текст запроса — без + него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break + (предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона + (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город. + Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри + области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim — + единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) — + city_hint должен реально влиять на его результат, не только на кэш-ключ. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", @@ -399,15 +621,19 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None: "Accept-Language": "ru,en;q=0.8", "Referer": "https://tradein-mvp.local/", } + city, _ = _resolve_city_for_geocode(address, city_hint) + query = f"{city}, {address}" if city else address async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: # Tier 1: оригинал - item = await _nominatim_query(client, address) + item = await _nominatim_query(client, query) # Tier 2: typo-variants if item is None: for variant in _typo_variants(address, limit=4): await asyncio.sleep(1.0) # Nominatim 1 req/sec policy - item = await _nominatim_query(client, variant) + variant_city, _ = _resolve_city_for_geocode(variant, city_hint) + variant_query = f"{variant_city}, {variant}" if variant_city else variant + item = await _nominatim_query(client, variant_query) if item is not None: logger.info("nominatim typo-fixed: %s → %s", address, variant) break @@ -425,133 +651,7 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None: ) -def _yandex_region_ok(geo_object: dict) -> bool | None: - """Кросс-чек региона по Yandex `AdministrativeAreaName`. - - True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую - область. None если поля нет / оно не строка — тогда accept-логика падает - обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри - генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой. - """ - try: - admin_area = ( - geo_object.get("metaDataProperty", {}) - .get("GeocoderMetaData", {}) - .get("AddressDetails", {}) - .get("Country", {}) - .get("AdministrativeArea", {}) - .get("AdministrativeAreaName") - ) - except AttributeError: - return None - if not isinstance(admin_area, str) or not admin_area: - return None - return "свердловск" in admin_area.lower() - - -# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ───────────── -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: - """Yandex Geocoder — 25K req/day free для самопод, лучше РФ. - - Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html - - Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов, - но БЕЗ rspn — чтобы fuzzy matching работал при опечатках. - """ - # Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66 - # (типичный кейс из suggest, либо явный запрос по другому городу области). - geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}" - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - "https://geocode-maps.yandex.ru/1.x/", - params={ - "apikey": api_key, - "geocode": geocode_query, - "format": "json", - "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже - "lang": "ru_RU", - "ll": EKB_BBOX["ll"], - "spn": EKB_BBOX["spn"], - }, - ) - response.raise_for_status() - data = response.json() - - members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - if not members: - return None - - # Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву - # при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass - # tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже - # если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат. - # Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из - # другого региона (Тюмень и т.п. попадают в генеральный bbox координатно). - best = None - oblast_fallback = None - for m in members: - obj = m.get("GeoObject", {}) - try: - lon_str, lat_str = obj["Point"]["pos"].split() - lat_f, lon_f = float(lat_str), float(lon_str) - except Exception: - continue - if _yandex_region_ok(obj) is False: - continue # регион явно не Свердловская область — не рассматриваем - if is_within_ekb_bbox(lat_f, lon_f): - best = obj - break - if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f): - oblast_fallback = obj - - if best is None: - best = oblast_fallback - - if best is None: - # Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно - # отсеяны по региону) → берём первый «как есть», НО только если его - # регион не опровергнут явно — иначе весь смысл region cross-check - # теряется на garbage-query, где top-1 — гарантированно не тот регион - # (например, Тюмень). - first_obj = members[0]["GeoObject"] - if _yandex_region_ok(first_obj) is not False: - best = first_obj - - if best is None: - return None - - lon_str, lat_str = best["Point"]["pos"].split() - precision_raw = ( - best.get("metaDataProperty", {}).get("GeocoderMetaData", {}).get("precision", "other") - ) - confidence_map = { - "exact": "exact", - "number": "exact", - "near": "approximate", - "range": "approximate", - "street": "approximate", - } - return GeocodeResult( - lat=float(lat_str), - lon=float(lon_str), - full_address=best.get("metaDataProperty", {}) - .get("GeocoderMetaData", {}) - .get("text", address), - provider="yandex", - confidence=confidence_map.get(precision_raw, "approximate"), - ) - - # ── Suggest (автокомплит) ─────────────────────────────────────────────────── -# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85 -# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного -# охвата (region 66) — см. OBLAST66_VIEWBOX выше. -EKB_BBOX = { - "viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE) - "ll": "60.605,56.838", # Yandex center (lon,lat) - "spn": "0.45,0.30", # Yandex span (lon,lat) -} @dataclass(frozen=True, slots=True) @@ -562,46 +662,14 @@ class GeocodeSuggestion: lon: float kind: str # 'house' / 'street' / 'locality' # ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов - # DaData-тира (fias_level 8/9). У Yandex/Nominatim/cadastral-тиров и у + # DaData-тира (fias_level 8/9). У Nominatim/cadastral-тиров и у # street/locality-кандидатов остаётся None (нет стабильного house-fias). fias_id: str | None = None -def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]: - """Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора.""" - out: list[GeocodeSuggestion] = [] - for m in members: - obj = m.get("GeoObject", {}) - try: - lon_str, lat_str = obj["Point"]["pos"].split() - meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) - kind = meta.get("kind", "other") - full = meta.get("text", obj.get("name", "")) - name = obj.get("name", full) - desc = obj.get("description", "") - desc_parts = [ - p.strip() - for p in desc.split(",") - if p.strip() and p.strip() not in {"Россия", "Свердловская область"} - ] - label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}" - out.append( - GeocodeSuggestion( - label=label, - full_address=full, - lat=float(lat_str), - lon=float(lon_str), - kind=kind, - ) - ) - except Exception: - continue - return out - - # ── DaData suggest wrapper ────────────────────────────────────────────────── # Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется -# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками). +# 'locality' вместо 'city' — consistent с Nominatim-веткой). _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"} @@ -612,13 +680,24 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] типа город/район, для autocomplete с привязкой к карте они бесполезны). Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»). - Constraint — вся область (region='Свердловская область', hard-filter внутри - `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc - никогда не появились бы в подсказках. + Constraint — вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter + внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/ + Серов/etc никогда не появились бы в подсказках. """ raw = await dadata.suggest_addresses( query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION ) + if not raw: + # Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в + # 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок). + # Отдельный warning, чтобы следующая такая регрессия была видна в логах, + # а не выглядела как «DaData ничего не знает про этот адрес». + logger.warning( + "dadata suggest: 0 кандидатов для %r при region=%r — " + "проверь, что констрейнт совпадает с полем DaData `region` (без типа)", + query[:60], + SVERDLOVSK_OBLAST_REGION, + ) out: list[GeocodeSuggestion] = [] for s in raw: if s.lat is None or s.lon is None: @@ -640,80 +719,6 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] return out -async def _yandex_geocode_request( - client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool -) -> list[dict]: - """Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1.""" - params: dict[str, str] = { - "apikey": api_key, - "geocode": query, - "format": "json", - "results": str(limit), - "lang": "ru_RU", - "ll": EKB_BBOX["ll"], - "spn": EKB_BBOX["spn"], - } - if bounded: - params["rspn"] = "1" - response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params) - response.raise_for_status() - data = response.json() - return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - - -@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) -async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]: - """Yandex Geocoder с авто-fallback на typo-tolerant режим. - - Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика. - Tier 2: bounded ЕКБ на typo-variants (удвоение согласных). - Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области - (region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2 - (bounded строго ЕКБ) структурно вернуть не могут. - """ - prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}" - async with httpx.AsyncClient(timeout=8.0) as client: - # Tier 1: strict bounded на оригинал (ЕКБ fast path) - members = await _yandex_geocode_request( - client, - api_key, - prefixed_query, - limit, - bounded=True, - ) - results = _parse_yandex_members(members) - if results: - return results - - # Tier 2: bounded на typo-варианты (тот же ЕКБ fast path) - for variant in _typo_variants(query, limit=4): - variant_query = ( - variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}" - ) - members = await _yandex_geocode_request( - client, - api_key, - variant_query, - limit, - bounded=True, - ) - results = _parse_yandex_members(members) - if results: - return results - - # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области - members = await _yandex_geocode_request( - client, - api_key, - prefixed_query, - limit, - bounded=False, - ) - results = _parse_yandex_members(members) - in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)] - return in_oblast - - async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]: """Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items.""" response = await client.get( @@ -733,31 +738,98 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i return data if isinstance(data, list) else [] -@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) -async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: - """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен). +def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]: + """Объединяет несколько списков raw Nominatim items в один, без дублей. - Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области - региона 66 — иначе не режем явные запросы по другим городам области. + Дедуп по `place_id` (если есть), иначе по округлённым координатам. Порядок + сохраняется: элементы из более раннего списка идут первыми (приоритет). + """ + seen: set[tuple[object, ...]] = set() + out: list[dict] = [] + for items in item_lists: + for item in items: + place_id = item.get("place_id") + key: tuple[object, ...] + if place_id is not None: + key = ("place_id", place_id) + else: + try: + key = ("latlon", round(float(item["lat"]), 5), round(float(item["lon"]), 5)) + except (KeyError, ValueError, TypeError): + key = ("raw", item.get("display_name")) + if key in seen: + continue + seen.add(key) + out.append(item) + return out + + +async def _nominatim_query_city_aware( + client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int +) -> list[dict]: + """Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город. + + Три случая (см. `_resolve_city_for_geocode`): + 1. `city` не None (`city_hint` подставлен) → один suffixed-запрос с ним. + 2. `city` is None, но `city_specified=True` (маркер УЖЕ в тексте адреса, + например "Екатеринбург, Малышева 30") → запрос БЕЗ доп. суффикса — город + уже есть в тексте, дублировать его нельзя (иначе "X, Екатеринбург, + Екатеринбург" ломает матчинг). + 3. `city` is None и `city_specified=False` — город НЕизвестен вообще (#2580 / + C2, regression test "Победы 20"): один bare-запрос БЕЗ текстового суффикса + неожиданно теряет часть настоящих ЕКБ-адресов — Nominatim без подсказки о + городе иногда предпочитает street-level матч в соседнем городе-спутнике + (напр. "Победы 20" без суффикса → улица Победы, Верхняя Пышма) более + специфичному named-place матчу в ЕКБ ("Парк Победы" ЖК, Екатеринбург). + Поэтому делаем ДВА запроса — bare (честный oblast-wide поиск, не теряет + реальные адреса других городов) И ЕКБ-suffixed (majority трафика) — и + ОБЪЕДИНЯЕМ результаты (не заменяем один другим): оба честных кандидата + остаются в списке, пользователь выбирает нужный сам из подсказок. + ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок). + """ + if city: + return await _nominatim_query_multi(client, f"{query}, {city}", limit) + if city_specified: + return await _nominatim_query_multi(client, query, limit) + ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) + await asyncio.sleep(1.0) # Nominatim 1 req/sec policy — два запроса подряд + bare_data = await _nominatim_query_multi(client, query, limit) + return _dedupe_nominatim_items(ekb_data, bare_data)[:limit] + + +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) +async def _nominatim_suggest( + query: str, limit: int = 8, city_hint: str | None = None +) -> list[GeocodeSuggestion]: + """Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный + запрос ничего не находит). + + Суффикс города навязывается, только если он известен: адрес уже называет + город/область региона 66, либо передан явный `city_hint`. Если город + НЕизвестен — см. `_nominatim_query_city_aware` (dual-query, C2): раньше + (#2576) здесь молча подставлялся суффикс ", Екатеринбург" всегда; чистое + удаление суффикса (без dual-query) регрессило часть реальных ЕКБ-адресов + (см. C2 в #2580) — поэтому оба честных варианта объединяются, не заменяют + друг друга. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", } - suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург" + city, city_specified = _resolve_city_for_geocode(query, city_hint) async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: # Tier 1: оригинальный query - data = await _nominatim_query_multi(client, suffixed_query, limit) + data = await _nominatim_query_city_aware(client, query, city, city_specified, limit) # Tier 2: typo-варианты если оригинал пустой if not data: for variant in _typo_variants(query, limit=3): await asyncio.sleep(1.0) # Nominatim 1 req/sec - variant_query = ( - variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург" + variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint) + data = await _nominatim_query_city_aware( + client, variant, variant_city, variant_specified, limit ) - data = await _nominatim_query_multi(client, variant_query, limit) if data: logger.info("nominatim suggest typo-fixed: %s → %s", query, variant) break @@ -928,23 +1000,85 @@ def _parse_street_house(address: str) -> tuple[str, str] | None: return (street, house) +# Извлечение номера дома из `readable_address` реестра. Реальные формы в +# gendesign_cad_buildings (47k строк, замер 2026-08-02): +# «д. 13» / «дом 13» / «сооружение 30» — 21k +# «д. 13б» — 2.6k +# «д. 13-б» — 2.1k +# «д. 13 б» — 125 +# «д. 58/3», «д. 64-2» — 0.9k (угловые/корпусные номера) +# «д. 11 (кв. 1-150)», «д. 102 корпус 1» — хвост, литерой НЕ является +# Разбор: +# \m… — маркер только с НАЧАЛА слова, иначе «проезд 8 +# Марта, д 5» дало бы дом «8» (старый `д\.?` без +# границы слова ловил «д» внутри «проезд») +# [0-9]+ — номер +# (\s*[-/]\s*[0-9]+)? — «58/3» / «64-2»: часть номера, а не мусор — +# иначе «58» ложно совпало бы с «58/3» +# (\s*-?\s*[а-яё](?![а-яё]))? — литера; lookahead отсекает начало слова +# («102 корпус 1» → «102», не «102к») +_SQL_HOUSE_TOKEN_RE = ( + r"\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*" + r"([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)" +) +# Нормализация извлечённого токена к канону `_norm_house`: убираем пробелы, +# затем дефис ТОЛЬКО перед литерой («23-б» → «23б», но «64-2» остаётся «64-2», +# иначе он схлопнулся бы в реальный дом «642»). +_SQL_HOUSE_TOKEN_NORM = ( + r"regexp_replace(" + r" regexp_replace(" + r" lower(COALESCE((regexp_match(readable_address, :house_token_re, 'i'))[1], ''))," + r" '\s', '', 'g')," + r" '-([а-яё])', '\1', 'g')" +) + + def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None: - """Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер. + """Anchored cadastral match: ILIKE по улице + СТРОГОЕ равенство номера дома. - SQL validated на проде (11/16 hits, 0 false positives). Anchor на - «д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона. + Литера — часть идентичности дома, а не украшение: «Новгородцевой 13б» и + «Новгородцевой 13» — РАЗНЫЕ здания. Поэтому номер сравнивается равенством + нормализованных форм (обе стороны → канон «13б»), а не «совпали цифры, + литера опциональна». - `street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём - только ЦИФРЫ дома (regex-safe) — конкатенируем bound-param внутри SQL. - Литеру (если есть) используем лишь для tie-break сортировки. + Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и + участвовала лишь как tie-break в ORDER BY — из-за чего запрос с литерой + молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 30» → «д. 30-б»), + причём с `confidence="exact"` и записью в `geocode_cache` на 90 дней. + + Regex-anchor на «д./дом/строение» (prefilter) сохранён: он дёшев, пушится + в FDW и убивает ложный матч номера внутри «(1-83)»-диапазона. Точность + даёт равенство токенов ниже. + + `street` идёт ТОЛЬКО в bound-param ILIKE, номер дома в regex больше НЕ + конкатенируется (сравнивается как текст) — regex-injection поверхность + сузилась до цифр prefilter'а. + + Нет дома с нужной литерой → возвращаем None, а НЕ «похожий» дом: пусть + отработают следующие тиры. Тихо подставленный соседний дом здесь + необратимо помечался бы `exact`. + + ВНИМАНИЕ, цепочки различаются — не путать: + * `geocode()` : geoportal → cadastral → `_cadastral_forward_sync` + → Nominatim → None. Тира DaData тут НЕТ. + * `suggest()` : cadastral → DaData → Nominatim (единственный вызов + `_dadata_suggest`). + То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`) + адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None — + оценка не строится. Это сознательный выбор: честный отказ вместо + уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет — + координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` → + `api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`). """ - house_digits_m = re.match(r"\d+", house) + house_norm = _norm_house(house) + house_digits_m = re.match(r"\d+", house_norm) if not house_digits_m: return None house_digits = house_digits_m.group(0) try: row = db.execute( - text(r""" + text( + r""" SELECT readable_address, lat, lon FROM gendesign_cad_buildings WHERE readable_address ILIKE CAST('%' || :street || '%' AS text) @@ -958,14 +1092,19 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge '(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский' || '| км|снт|гараж|коллективный сад)' ) - ORDER BY - (CASE WHEN CAST(:house_full AS text) ~ '[а-яё]' - AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)') - THEN 0 ELSE 1 END), - length(readable_address) ASC + AND """ + + _SQL_HOUSE_TOKEN_NORM + + r""" = CAST(:house_norm AS text) + ORDER BY length(readable_address) ASC LIMIT 1 - """), - {"street": street, "house_digits": house_digits, "house_full": house}, + """ + ), + { + "street": street, + "house_digits": house_digits, + "house_norm": house_norm, + "house_token_re": _SQL_HOUSE_TOKEN_RE, + }, ).first() except Exception: logger.warning( @@ -1086,24 +1225,35 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = return str(row.readable_address) -async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]: +async def suggest( + query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None +) -> list[GeocodeSuggestion]: """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, - остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → []. + остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → []. db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier). - DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который - заблокирован (1k/день demo limit исчерпан). + city_hint: город, если известен вызывающему коду (#2576) — прокидывается в + Nominatim-тир, чтобы НЕ подставлять "Екатеринбург" молча, когда + пользователь его не называл. Опционально, backward-compatible (None — + прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`). + DaData /suggest (PR Q2) — token-only, 10k/день, лучший fit для РФ-адресов + (#2593: Yandex Geocoder, который был primary external provider до DaData, + удалён). DaData region-constraint уже охватывает всю область (не только + ЕКБ) — city_hint ей не нужен. Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). """ if not query or len(query.strip()) < 2: return [] # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов. - # EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет - # другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом - # с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже - # oblast-aware. - if db is not None and not _names_non_ekb_city(query): + # EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно + # называет другой топоним: известный город области (`_names_non_ekb_city`), + # незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ + # `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 — + # иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed` + # и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим — + # они уже oblast-aware. + if db is not None and _ekb_local_tiers_allowed(query, city_hint): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. parsed = _parse_street_house(query.strip()) @@ -1118,63 +1268,74 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list return cad_results # Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free, - # лучший fit для РФ адресов. Заменил Yandex как primary external provider - # после того как demo-key Yandex (1k/день) был исчерпан. + # лучший fit для РФ адресов. if settings.dadata_api_token: try: dadata_results = await _dadata_suggest(query, limit) if dadata_results: return dadata_results except Exception: - logger.exception("dadata suggest failed, falling back to yandex") + logger.exception("dadata suggest failed, falling back to nominatim") - # Tier 3: Yandex (legacy — оставляем как fallback, если key есть) - if settings.yandex_geocoder_api_key: - try: - results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit) - if results: - return results - except Exception: - logger.exception("yandex suggest failed, falling back to nominatim") - - # Tier 4: Nominatim (последний fallback — OSM, без ключа) + # Tier 3: Nominatim (последний fallback — OSM, без ключа) try: - return await _nominatim_suggest(query, limit) + return await _nominatim_suggest(query, limit, city_hint=city_hint) except Exception: logger.exception("nominatim suggest failed") return [] # ── Public API ─────────────────────────────────────────────────────────────── -async def geocode(address: str, db: Session) -> GeocodeResult | None: - """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None. +async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: + """Геокодинг с кэшем. Cadastral FDW → Nominatim → None. Args: address: пользовательский ввод (может быть грязным — нормализуем). db: сессия Postgres для cache lookup/write и cadastral FDW lookup. + city_hint: город, если известен вызывающему коду (#2576) — например + выбран пользователем на предыдущем шаге UI. Опциональный, не + ломает существующий контракт. Прокидывается в Nominatim-тир + вместо молчаливой подстановки "Екатеринбург" и + участвует в cache-ключе (см. `_cache_key`), чтобы ответы для + разных городов по одному и тому же тексту адреса не перезатирали + друг друга. Returns: GeocodeResult или None если ни один провайдер не отвечает. + `result.city_ambiguous=True`, если ни адрес, ни `city_hint` не + называли город явно — итоговый город определил провайдер/локальный + тир, а не пользователь (честный сигнал, не эвристика на корректность). """ if not address or len(address.strip()) < 3: return None - addr_norm = normalize_address(address) + _, city_specified = _resolve_city_for_geocode(address, city_hint) + city_ambiguous = not city_specified + + addr_norm = _cache_key(normalize_address(address), city_hint) # 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop) cached = await asyncio.to_thread(_cache_get, db, addr_norm) if cached is not None: logger.info("geocode cache hit: %s", addr_norm) - return cached + return replace(cached, city_ambiguous=city_ambiguous) # 2. Локальные источники по street+house (без внешнего API). parsed = _parse_street_house(address.strip()) - # #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на - # адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом - # (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в - # ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4). - use_local_ekb = not _names_non_ekb_city(address) + # #2582 (было #11): EKB-only локальные тиры (geoportal/cadastral) не должны + # отвечать на адрес другого города/региона — иначе улица+дом, коллизящие с + # ЕКБ-домом (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), + # снапаются в ЕКБ. Fail-closed: разрешаем локальные тиры ТОЛЬКО если + # Екатеринбург подтверждён явно (текстом или `city_hint`, #2580 C1 — + # иначе caller, явно передавший city_hint="Нижний Тагил" для "Ленина 1" + # без города в самом тексте, всё равно получал бы "точный" ЕКБ-хит с + # city_ambiguous=False — ложно-уверенный неверный результат), или + # город/регион вообще не назван нигде — см. `_ekb_local_tiers_allowed`. + # Раньше решение по тексту адреса принималось от противного (список из 37 + # городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион + # РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582). + use_local_ekb = _ekb_local_tiers_allowed(address, city_hint) # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%). if use_local_ekb and parsed is not None: @@ -1191,6 +1352,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: full_address=hit.full_address, provider="cache", confidence="exact", + city_ambiguous=city_ambiguous, ) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( @@ -1215,6 +1377,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: full_address=hit.full_address, provider="nominatim", # treat as "local" — same confidence as nominatim confidence="exact", + city_ambiguous=city_ambiguous, ) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( @@ -1238,6 +1401,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: full_address=s.full_address, provider="nominatim", # treat as "local" — same confidence as nominatim confidence="exact", + city_ambiguous=city_ambiguous, ) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info( @@ -1245,41 +1409,11 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: ) return result - # 3. Yandex (если есть key) с typo-fallback - if settings.yandex_geocoder_api_key: - try: - result = await _yandex_lookup(address, settings.yandex_geocoder_api_key) - # Если результат вне области (region 66) — пробуем typo-варианты - in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon) - if result is not None and in_oblast: - await asyncio.to_thread(_cache_put, db, addr_norm, result) - logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) - return result - # Tier 2: typo-variants - for variant in _typo_variants(address, limit=4): - try: - result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key) - except Exception: - continue - if result is None: - continue - if is_within_oblast66_bbox(result.lat, result.lon): - await asyncio.to_thread(_cache_put, db, addr_norm, result) - logger.info( - "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)", - addr_norm, - variant, - result.lat, - result.lon, - ) - return result - except Exception: - logger.exception("yandex geocoder failed, falling back to nominatim") - - # 4. Nominatim fallback + # 3. Nominatim fallback try: - result = await _nominatim_lookup(address) + result = await _nominatim_lookup(address, city_hint) if result is not None: + result = replace(result, city_ambiguous=city_ambiguous) await asyncio.to_thread(_cache_put, db, addr_norm, result) logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса @@ -1293,9 +1427,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # ── Reverse: координаты → адрес (для map-picker'а) ────────────────────────── # Precision levels which we treat as "снап к зданию имеет смысл": -# - exact — точный матч на здание (Yandex) +# - exact — точный матч на здание. #2593: единственный живой источник +# (`_yandex_reverse`) удалён — значение сейчас недостижимо ни одним из +# оставшихся тиров (cadastral/Nominatim), но оставлено в множестве: +# дёшево держать (frozen set membership-check, не enum), сохраняет parity +# с фронтовым `SNAP_PRECISIONS` (`MapPicker.tsx`, тот же литерал), и не +# ломает `test_snap_precision_useful_exact_and_number`. # - number — найден дом с номером (то что нам надо для квартирного оценщика) -# - cadastral — Cadastral FDW row (то же по точности что Yandex "number") +# - cadastral — Cadastral FDW row (та же точность, что и "number") # Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике. _SNAP_PRECISIONS = {"exact", "number", "cadastral"} @@ -1308,14 +1447,14 @@ class ReverseGeocodeResult: - `snapped_lat` — координата центра здания если provider дал её, иначе echo `lat` входной точки (для precision=street/locality). - `snapped_lon` — то же. - - `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/ - `locality`/`other`/`cadastral`. Используется фронтом чтобы + - `precision` — `exact`/`number`/`street`/`range`/`near`/`locality`/ + `other`/`cadastral`. Используется фронтом чтобы решить — двигать marker (exact/number) или нет. - - `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`). + - `provider` — кто дал результат (`nominatim`/`cadastral`). Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number` и snapped >5m от click point — пересаживает marker на snapped point - (чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе). + (чтобы пользователь видел центр дома, а не свой клик во дворе). Для остальных precision marker остаётся где кликнули — не врём что нашли точное здание. """ @@ -1324,7 +1463,7 @@ class ReverseGeocodeResult: snapped_lat: float snapped_lon: float precision: str - provider: Literal["yandex", "nominatim", "cadastral"] + provider: Literal["nominatim", "cadastral"] def _format_reverse_address(addr: dict) -> str | None: @@ -1351,77 +1490,6 @@ def _format_reverse_address(addr: dict) -> str | None: return ", ".join(parts) -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocodeResult | None: - """Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision. - - Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html - Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!). - """ - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - "https://geocode-maps.yandex.ru/1.x/", - params={ - "apikey": api_key, - "geocode": f"{lon},{lat}", - "format": "json", - "results": "1", - "kind": "house", # просим именно здание (house), не улицу - "lang": "ru_RU", - }, - ) - response.raise_for_status() - data = response.json() - - members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - if not members: - return None - obj = members[0].get("GeoObject", {}) - try: - lon_str, lat_str = obj["Point"]["pos"].split() - snapped_lat = float(lat_str) - snapped_lon = float(lon_str) - except (KeyError, ValueError): - return None - meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) - precision = str(meta.get("precision", "other")) - address_text = str(meta.get("text") or obj.get("name") or "") - # Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix, - # оставляем «улица, дом, город» для consistency с Nominatim/cadastral. - if address_text: - # «Россия, Свердловская область, Екатеринбург, улица Малышева, 51» - # → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street) - parts = [p.strip() for p in address_text.split(",") if p.strip()] - filtered = [ - p - for p in parts - if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ") - ] - # Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим - # в конец — любой город региона 66, не только ЕКБ (case-insensitive match). - locality = None - rest: list[str] = [] - for p in filtered: - if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES: - locality = p - else: - rest.append(p) - if locality and rest: - address_text = ", ".join([*rest, locality]) - else: - address_text = ", ".join(filtered) - - if not address_text: - return None - return ReverseGeocodeResult( - address=address_text, - snapped_lat=snapped_lat, - snapped_lon=snapped_lon, - precision=precision, - provider="yandex", - ) - - @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. @@ -1521,7 +1589,7 @@ def _cadastral_reverse_sync_full( async def reverse_geocode( lat: float, lon: float, db: Session | None = None ) -> ReverseGeocodeResult | None: - """Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords. + """Cadastral FDW → Nominatim. Возвращает snapped coords. Возвращает None если ни один источник не дал адрес. Endpoint api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError @@ -1546,16 +1614,7 @@ async def reverse_geocode( provider="cadastral", ) - # 2. Yandex — основной источник snap'а (его Point.pos = центр здания) - if settings.yandex_geocoder_api_key: - try: - result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key) - if result is not None: - return result - except Exception: - logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon) - - # 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) + # 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) try: return await _nominatim_reverse(lat, lon) except Exception: diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index ede3356b..330b66c0 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -132,9 +132,16 @@ _COMPLETENESS_EXPR = """ # Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they # agree row-for-row. Priority: geom present → most linked listings → most-populated → min id. +# +# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома +# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём +# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному +# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую +# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома / +# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно. _KEEPER_ORDER = f""" (h.geom IS NOT NULL) DESC, - listing_cnt DESC, + listing_cnt DESC NULLS LAST, ({_COMPLETENESS_EXPR}) DESC, h.id ASC """ diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 38bdd005..edb02137 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -30,6 +30,7 @@ from dataclasses import dataclass, field from typing import Literal from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.house_type_normalizer import normalize_house_type # #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска, # что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex @@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5 # ── house_type normalisation ───────────────────────────────────────────────── +# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type), +# значения — вокабуляр Avito IMV. _HOUSE_TYPE_TO_IMV: dict[str, str] = { "panel": "panel", "brick": "brick", "monolith": "monolithic", - "monolithic": "monolithic", "monolith_brick": "monolithic", # Avito API не принимает гибриды "block": "block", "wood": "wood", } -_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ -def _map_house_type(raw: str | None) -> str: - if not raw: - return _HOUSE_TYPE_DEFAULT - return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT) +def _map_house_type(raw: str | None) -> str | None: + """Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём. + + Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он + знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / + aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное + ('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру + тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому + 'monolithbrick' в словарь не попадал. + + #2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он + есть, но не распознан. Панель — почти самый дешёвый класс (медиана по нашим же + 2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k + ₽/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем + без типа + 75 домов с camelCase-типом (56 из них monolithBrick, −11.7% к + monolithic) уехали как панель. Теперь неизвестный тип → None → дом помечается + и запрос к площадке не тратится (см. _process_one_house). + """ + canon = normalize_house_type(raw) + if canon is None: + return None + return _HOUSE_TYPE_TO_IMV.get(canon) + + +def _map_renovation_type(repair_state: str | None) -> str: + """listings.repair_state → renovation_type вокабуляра Avito IMV. + + Переиспользуем _IMV_REPAIR_MAP эстиматора — единственный источник правды для + этого соответствия (needs_repair→required / standard→cosmetic / good→euro / + excellent→designer). Импорт ленивый: estimator тянет scraper_adapters, а тот + импортирует этот модуль (circular — см. блок импортов выше). + + #2674: раньше здесь стоял литерал 'cosmetic' — все 2685 запросов ушли как + «косметический ремонт», хотя мода по объявлениям этих же домов совсем другая + (standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика + лишь 36%). + + Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state) + ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic' + (=standard) — это одновременно МОДА и МЕДИАННАЯ категория популяции + (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно + needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка. + У типа дома такой догадки нет: 'panel' — почти край шкалы, а не её середина. + + Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при + неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not + None`), а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех, + что уже отсекает неизвестный тип дома. + """ + from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок + + mapped = _IMV_REPAIR_MAP.get(repair_state) + if mapped is None and repair_state: + # Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте + # (сырых repair-значений в listings больше, чем нормализованных). Паритет + # с house_type_normalizer, который такой случай уже логирует. + logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state) + return mapped or "cosmetic" # ── Region bbox prefix для Avito geocoder ──────────────────────────────────── @@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict: AS integer) AS floor, CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors) AS integer) AS total_floors, - mode() WITHIN GROUP (ORDER BY house_type) AS house_type + mode() WITHIN GROUP (ORDER BY house_type) AS house_type, + mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state FROM listings WHERE house_id_fk = :hid AND rooms IS NOT NULL @@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict: "floor": floor, "floor_at_home": floor_at_home, "house_type": _map_house_type(row["house_type"] or (house and house["house_type"])), - "renovation_type": "cosmetic", + "renovation_type": _map_renovation_type(row["repair_state"]), + # has_balcony/has_loggia остаются константами намеренно (#2674): покрытие + # listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки + # противоречат друг другу (по has_balcony «есть» у 62%, а по + # balcony_loggia самый частый случай — loggia 5650 против balcony 2794). + # Мода по одному-двум объявлениям на таком покрытии — шум, а не данные. "has_balcony": True, "has_loggia": False, } @@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat ) # 3. Suggestions + # #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/ + # total_floors — колонки есть с миграции 064, но писатель их не заполнял + # (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в + # suggestions.items[].imageLink, метрики квартиры парсятся из title. for sug in result.suggestions: db.execute( text(""" INSERT INTO house_suggestions ( house_id, ext_item_id, title, address, price_rub, + area_m2, rooms, floor, total_floors, exposure_days, publish_date, - item_link, metro_name, metro_distance, + item_link, image_link, metro_name, metro_distance, has_good_price_badge, raw_payload, fetched_at ) VALUES ( :hid, :ext, :title, :addr, :price, + CAST(:area AS numeric), :rooms, :floor, :total_floors, :exp, :pdate, - :link, :mname, :mdist, + :link, :img, :mname, :mdist, :gpb, CAST(:raw AS jsonb), NOW() ) ON CONFLICT (house_id, ext_item_id) DO UPDATE SET title = EXCLUDED.title, price_rub = EXCLUDED.price_rub, + area_m2 = EXCLUDED.area_m2, + rooms = EXCLUDED.rooms, + floor = EXCLUDED.floor, + total_floors = EXCLUDED.total_floors, exposure_days = EXCLUDED.exposure_days, publish_date = EXCLUDED.publish_date, item_link = EXCLUDED.item_link, + image_link = EXCLUDED.image_link, metro_name = EXCLUDED.metro_name, metro_distance = EXCLUDED.metro_distance, has_good_price_badge = EXCLUDED.has_good_price_badge, @@ -316,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat "title": sug.title, "addr": sug.address, "price": sug.price_rub, + "area": sug.area_m2, + "rooms": sug.rooms, + "floor": sug.floor, + "total_floors": sug.total_floors, "exp": sug.exposure_days, "pdate": sug.publish_date, "link": sug.item_url, + "img": sug.image_link, "mname": sug.metro_name, "mdist": sug.metro_distance, "gpb": sug.has_good_price_badge, @@ -500,8 +577,33 @@ async def backfill_house_imv( # + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853). # Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше # (поведение байт-в-байт идентично доспринтовому). + # + # #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не + # опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар + # берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1 + # (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire + # («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от + # fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток + # каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем + # 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока + # avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через + # ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже: + # запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch + # камуфокса на каждый дом (server.py::_ensure_browser). if settings.avito_imv_use_browser_fetcher: - async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf: + # lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig. + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig + + _cfg = RealScraperConfig() + async with BrowserFetcher( + source="avito", + endpoint=settings.browser_http_endpoint, + proxy_provider=RealProxyProvider(), + use_pool=_cfg.use_proxy_pool_browser, + # #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути — + # фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease). + environment=_cfg.environment, + ) as _bf: await _run_loop(_bf) else: await _run_loop(None) @@ -652,6 +754,14 @@ async def _process_one_house( _mark_status(db, hid, "no_params", "no listings with rooms+area") return "no_params" + # #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо + # вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и + # занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же + # путь, что и при отсутствии комнат/площади. + if params["house_type"] is None: + _mark_status(db, hid, "no_params", "unknown house_type") + return "no_params" + address = house.get("address") or house.get("full_address") if not address: _mark_status(db, hid, "no_address", "house.address is NULL") diff --git a/tradein-mvp/backend/app/services/identity_store.py b/tradein-mvp/backend/app/services/identity_store.py new file mode 100644 index 00000000..f20ec47e --- /dev/null +++ b/tradein-mvp/backend/app/services/identity_store.py @@ -0,0 +1,291 @@ +"""Единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ живёт identity. + +Эпик «единый вход»: люди «Меры» (trade-in) и «Птицы» (Site Finder) переезжают в +общую БД `auth` (`users` / `sessions`, миграции data/sql/auth/001-004), а +`tradein_users` в итоге удаляется. Переезд идёт под флагом +`settings.identity_store`, дефолт которого = СТАРОЕ поведение: + + "tradein" (ДЕФОЛТ) — БД tradein, tradein_users / tradein_sessions; + "auth" — БД auth, users / sessions. + +Смысл модуля: во всём остальном коде не должно быть ни одного упоминания +конкретной БД, конкретных имён таблиц и того, каким столбцом выражено состояние +доступа. Кто хочет читать/писать людей и сессии — спрашивает здесь. + +Что модуль отдаёт вызывающему: + * `identity_session()` / `get_identity_db()` — сессия ТОЙ БД, которая сейчас + является реестром (для "tradein" это ровно `app.core.db.SessionLocal`, то + есть сегодняшний прод-путь без единого лишнего коннекта); + * `identity_schema()` — имена таблиц users/sessions и имя колонки состояния + доступа; + * `AccessState` + `to_access_state()` — ОДНО понятие «состояние доступа» для + обеих схем. + +Схемы `tradein_users` и `auth.users` совпадают, кроме состояния доступа: +`tradein_users.is_active` — boolean, `auth.users.access_state` — text из трёх +значений (`active` / `trial_expired` / `disabled`, семантика — в COMMENT'е +миграции 004). Вызывающий код обязан работать с ОДНИМ понятием: он читает +колонку `schema.access_state_column` и прогоняет значение через +`to_access_state()`. Второго представления состояния в коде быть не должно — +`if row.is_active` вне этого модуля больше не пишем. + +Как СПРАШИВАТЬ состояние доступа (канонический вызов): + + schema = identity_schema() + with identity_session() as db: + row = db.execute( + text( + f"SELECT u.id, u.username, u.role, " + f" u.{schema.access_state_column} AS access_state " + f" FROM {schema.users_table} u " + f" WHERE u.username = :username" + ), + {"username": username}, + ).fetchone() + state = to_access_state(row.access_state) + if not state.can_sign_in: + ... # 401 для disabled, отдельный 403 для AccessState.TRIAL_EXPIRED + +Значение подставляется bind-параметром (`:username`), имя таблицы и имя колонки — +из `schema`, то есть из фиксированного словаря; в SQL-строку не попадает ничего, +пришедшего снаружи. + +Как ПИСАТЬ состояние доступа (обратное направление, `access_state_param()`): + + db.execute( + text( + f"UPDATE {schema.users_table} " + f" SET {schema.access_state_column} = :access_state " + f" WHERE id = :id" + ), + {"access_state": access_state_param(AccessState.DISABLED), "id": user_id}, + ) + +Литералов `True` / `'active'` по месту быть не должно: тип колонки разный, и +единственное место, знающее какой, — этот модуль. + +⚠️ SQL-инъекция по имени таблицы: имена таблиц/колонок в SQL нельзя передать +bind-параметром, поэтому они подставляются в строку запроса. Единственный +допустимый источник — фиксированный словарь `_SCHEMAS` НИЖЕ. Никакой +конкатенации с внешним вводом (заголовок, тело запроса, переменная окружения, +имя роли) — значение `settings.identity_store` ограничено `Literal` в pydantic, +и лукап по нему делается только здесь. +""" + +from __future__ import annotations + +import logging +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from enum import StrEnum +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.orm import Session + +from app.core import auth_db +from app.core.config import settings +from app.core.db import SessionLocal, get_db + +logger = logging.getLogger(__name__) + + +class AccessState(StrEnum): + """Состояние доступа аккаунта — ЕДИНОЕ понятие для обеих схем. + + Значения дословно совпадают с `auth.users.access_state` (CHECK-констрейнт + `users_access_state_ck`, миграция 004); булев `tradein_users.is_active` + приводится сюда в `to_access_state()`. + + Семантика (COMMENT миграции 004, решение владельца от 2026-07-31): + active — вход разрешён; + trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: отдельный 403 и + экран «пробный доступ закончился», сессия не выдаётся; + disabled — доступ закрыт: generic 401, для пользователя неотличимо от + неверного пароля. + Неверный пароль в ЛЮБОМ состоянии → generic 401, иначе отдельный ответ для + trial_expired превращается в оракул существования логина. + """ + + ACTIVE = "active" + TRIAL_EXPIRED = "trial_expired" + DISABLED = "disabled" + + @property + def can_sign_in(self) -> bool: + """True только для `active` — единственная проверка «пускать ли». + + Вынесена в свойство, чтобы вызывающий не писал `state == "active"`: + добавится четвёртое состояние — оно по умолчанию окажется «не пускать», + а не «пускать, потому что не disabled». + """ + return self is AccessState.ACTIVE + + +@dataclass(frozen=True, slots=True) +class IdentitySchema: + """Где физически лежит identity при текущем значении флага. + + Attributes: + store: значение `settings.identity_store`, которому соответствует схема. + users_table: имя таблицы людей. + sessions_table: имя таблицы сессий. + access_state_column: имя колонки состояния доступа. Значение из неё + ОБЯЗАНО пройти через `to_access_state()` — тип отличается между + схемами (boolean против text). + access_state_sql_type: SQL-тип этой колонки для `CAST(:param AS ...)`. + Нужен там, где параметр может быть NULL (`COALESCE(CAST(:x AS T), col)` + в PATCH «Команды»): без явного типа Postgres не может вывести тип + NULL-параметра. Значение — литерал из `_SCHEMAS`, в SQL-строку + снаружи ничего не попадает. + """ + + store: str + users_table: str + sessions_table: str + access_state_column: str + access_state_sql_type: str + + +# Фиксированный словарь — ЕДИНСТВЕННЫЙ источник имён таблиц/колонок для SQL. +# Ключи = допустимые значения settings.identity_store (Literal в pydantic). +_SCHEMAS: dict[str, IdentitySchema] = { + "tradein": IdentitySchema( + store="tradein", + users_table="tradein_users", + sessions_table="tradein_sessions", + access_state_column="is_active", + access_state_sql_type="boolean", + ), + "auth": IdentitySchema( + store="auth", + # В БД `auth` таблицы лежат без префикса продукта — реестр общий + # (data/sql/auth/001_identity_schema.sql). + users_table="users", + sessions_table="sessions", + access_state_column="access_state", + access_state_sql_type="text", + ), +} + + +def identity_schema() -> IdentitySchema: + """Схема реестра для текущего значения `settings.identity_store`. + + Читается на КАЖДОМ вызове, а не кешируется на импорте: тесты и + переключение флага не должны требовать перезагрузки модулей. + """ + schema = _SCHEMAS.get(settings.identity_store) + if schema is None: + # Недостижимо через настройки (Literal валидируется pydantic), но + # молчаливый fallback здесь означал бы поход не в ту БД. + raise ValueError(f"неизвестный identity_store={settings.identity_store!r}") + return schema + + +@contextmanager +def identity_session() -> Iterator[Session]: + """Сессия БД, в которой сейчас живёт identity. + + "tradein" → `app.core.db.SessionLocal` (та же БД и тот же пул, что у всего + остального приложения — сегодняшнее поведение прода без изменений). + "auth" → ленивый engine `app.core.auth_db`; пустой `AUTH_DATABASE_URL` + здесь поднимет `AuthDatabaseNotConfiguredError`, а не отдаст пустой + результат. + """ + if settings.identity_store == "auth": + with auth_db.auth_session() as db: + yield db + else: + with SessionLocal() as db: + yield db + + +def get_identity_db( + db: Annotated[Session, Depends(get_db)], +) -> Generator[Session, None, None]: + """FastAPI-зависимость: `db: Annotated[Session, Depends(get_identity_db)]`. + + Аналог `app.core.db.get_db`, но для реестра людей. Роуты, работающие с + identity, обязаны брать сессию отсюда — иначе при `identity_store="auth"` + они уйдут запросом в БД tradein, где нужных таблиц уже не будет. + + ⚠️ При `identity_store="tradein"` отдаётся РОВНО ТОТ ЖЕ объект `Session`, + что и у `Depends(get_db)` — не новая сессия к той же БД. Это не экономия + коннекта, а требование «прод обязан работать точно как сейчас»: роуты + «Команды» пишут в ОДНОЙ транзакции строку сотрудника (реестр) и его квоту + (`account_quota_overrides`, продуктовая таблица). Две сессии = две + транзакции = состояние «сотрудник создан, квота нет» на ровном месте. + FastAPI кеширует результат `Depends(get_db)` в пределах запроса, поэтому + роут, объявивший ОБЕ зависимости, в этом режиме получает один и тот же + объект, и `db is identity_db` — честный рантайм-признак «одна БД». + + При `identity_store="auth"` это разные БД физически, и одной транзакции + быть не может (двухфазный коммит здесь не заводим): вызывающий код обязан + коммитить обе сессии и понимать порядок — см. `app.api.v1.team`. + Зависимость `get_db` при этом всё равно резолвится, но `Session` ленив — + без единого запроса он коннект не открывает, так что лишнего соединения с + БД tradein не появляется. + """ + if settings.identity_store != "auth": + yield db + return + with auth_db.auth_session() as identity_db: + yield identity_db + + +def to_access_state(value: object) -> AccessState: + """Приводит значение колонки состояния доступа к `AccessState`. + + ЕДИНСТВЕННОЕ место, где булев `tradein_users.is_active` превращается в + трёхзначное состояние: True → `active`, False → `disabled` (жёсткая + блокировка, generic 401 — ровно то, что булева схема и означала). + `trial_expired` в булевой схеме выразить нечем: состояния там не + существовало, и на tradein-пути оно не появится. + + Fail-closed: неизвестная строка, NULL и любой неожиданный тип → `disabled` + + WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что + новое состояние, добавленное миграцией раньше кода, молча раздаёт доступ. + """ + if isinstance(value, bool): + return AccessState.ACTIVE if value else AccessState.DISABLED + if isinstance(value, str): + try: + return AccessState(value) + except ValueError: + logger.warning( + "identity_store: неизвестное состояние доступа %r → трактую как disabled", value + ) + return AccessState.DISABLED + logger.warning( + "identity_store: состояние доступа %r неожиданного типа %s → трактую как disabled", + value, + type(value).__name__, + ) + return AccessState.DISABLED + + +def access_state_param(state: AccessState) -> bool | str: + """Значение для ЗАПИСИ в `schema.access_state_column` — обратная к `to_access_state()`. + + Тип колонки разный (boolean против text), поэтому конверсию нельзя оставить + вызывающему: он бы неизбежно писал `True`/`'active'` по месту, и это ровно + то второе представление состояния, которого в коде быть не должно. + + Для булевой схемы `trial_expired` невыразим — там существуют только «пустят» + и «не пустят», и попытка записать промежуточное состояние молча стала бы + жёсткой блокировкой (клиент увидел бы «неверный пароль» вместо экрана + пробного периода). Поэтому это ошибка вызывающего, а не тихое приведение: + писать `trial_expired` можно только при `identity_store="auth"`. + """ + schema = identity_schema() + if schema.access_state_sql_type == "boolean": + if state is AccessState.TRIAL_EXPIRED: + raise ValueError( + f"состояние {state.value!r} невыразимо в схеме {schema.store!r} " + f"(колонка {schema.access_state_column} — boolean): доступны только " + f"{AccessState.ACTIVE.value!r} и {AccessState.DISABLED.value!r}" + ) + return state.can_sign_in + return state.value diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 7b38d30e..8e1c67f3 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -13,9 +13,13 @@ POI-score его не улавливал (POI ranking ≠ цена). НОВЫЙ ПОКАЗАТЕЛЬ (location index): - location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки − + location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки − медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100 +«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие +(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И +только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы). + Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую ("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) — медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат @@ -45,6 +49,11 @@ from typing import Any from pydantic import BaseModel from sqlalchemy import text +# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте. +# Дублировать значение здесь нельзя: две константы разъедутся при первой же +# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена. +from app.services.estimator import LISTINGS_FRESH_DAYS + logger = logging.getLogger(__name__) # ── Гео-охват продукта: только Екатеринбург ────────────────────────────────── @@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк # (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # +# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора. +# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение +# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision): +# +# было (только is_active) 30 222 строк 172 984 ₽/м² +# + только свежесть 11 453 строк 163 363 ₽/м² +# + только сегмент 11 219 строк 147 632 ₽/м² +# стало (оба) 7 715 строк 147 368 ₽/м² +# +# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8% +# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек +# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011. +# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк +# пула 15 265 — новостройки, и гард выносит их заодно. +# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего +# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на +# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка — +# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных +# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней +# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а +# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% / +# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы. +# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число +# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо, +# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше). +# +# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем +# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт +# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до +# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что +# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический. +# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая. +# +# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов +# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: +# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. +# # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если # сам продукт уже скрейпит соседние города области (city-sweep): географию location_index # явно ограничил владелец продукта. @@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text( AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text( AND price_per_m2 IS NOT NULL AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND (geo_precision IS DISTINCT FROM 'city') + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[ "lat": lat, "lon": lon, "radius_m": radius_m, + "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, @@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]: db.execute( _MEDIAN_PPM2_CITYWIDE_SQL, { + "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, "bbox_south": _EKB_BBOX_SOUTH, diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 2b130d29..413b82ca 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -1,11 +1,40 @@ """House cross-source matching — tiered algorithm. -Tier 0 (confidence 1.0): cadastral_number exact match on houses table. -Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. -Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. -Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. -Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). -New (confidence 1.0): INSERT new canonical house. +`match_or_create_house` (путь скрейпинга, создаёт дома): + Tier 0 (confidence 1.0): cadastral_number exact match on houses table. + Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. + Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. + Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). + New (confidence 1.0): INSERT new canonical house. + +`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно +имеет Tier 0.5 fias_exact — у него ЕСТЬ источник ФИАС (DaData /suggest в +`estimator.resolve_target_house`), см. docstring функции. + +ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources): + fingerprint 58.97% · new 22.65% · geo_proximity 18.36% · + **cadastr_exact 0 · fias_exact 0** — верхние тиры не срабатывали НИ РАЗУ. + + • Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id` + нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в + `app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих + (`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`) — + передать его было НЕКОМУ. Регресс сторожит + tests/test_matching_tier_reachability_2674.py. + • Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot. + building_cadastral_number` → адаптер → сюда), но площадки кадастр не отдают: + `listings.cadastral_number` 0/93 408, а все 28 504 заполненных + `listings.building_cadastral_number` — на 100% из локального гео-зеркала ЕГРН + (`tasks/cadastral_geo_match.py`, KNN ≤50 м), т.е. появляются ПОСЛЕ матчинга и + обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр + не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы + over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка + начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр + `cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих. + Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда, + падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и + попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на + квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное. Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3 """ @@ -46,8 +75,6 @@ def match_or_create_house( *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, - house_fias_id: str | None = None, source_url: str | None = None, ) -> tuple[int | None, float, str]: """Match existing house or create new canonical record. @@ -58,21 +85,18 @@ def match_or_create_house( for an unknown address could both miss Tier 0-3 and each INSERT a duplicate house row. Closes finding #1 from 2026-05-24 audit. - Args: - house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream - (e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and - optional, existing callers are unaffected. + NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля. + ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС. Returns: (house_id, confidence ∈ [0.0, 1.0], method ∈ { - 'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint', + 'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new', 'no_house_number' }) house_id is None only for the 'no_house_number' terminal case below. Method values: 'cadastr_exact' — matched by cadastral number (confidence 1.0) - 'fias_exact' — matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95) 'source_exact' — already in house_sources for this source+ext_id (confidence 1.0) 'fingerprint' — matched by address fingerprint (confidence 0.9) 'geo_proximity' — matched by geo within 30 m (confidence 0.7) @@ -87,7 +111,16 @@ def match_or_create_house( 'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building identity, so cad-carrying rows stay exempt (Tier 0 owns them). """ - cad = building_cadastral_number or cadastral_number + # ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`, + # где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из + # сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка, + # ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]` + # (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы + # никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ + # проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без + # кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы + # два дома — то самое дробление, против которого Tier 0 и заведён. + cad = building_cadastral_number # Compute fingerprint early so we can acquire the advisory lock before any tier reads. fp = address_fingerprint(address, lat, lon) @@ -136,34 +169,10 @@ def match_or_create_house( logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad) return (house_id, 1.0, "cadastr_exact") - # Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. - # Stable ORDER BY id so concurrent/duplicate rows resolve deterministically. - if house_fias_id: - row = ( - db.execute( - text( - "SELECT id FROM houses " - "WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) " - "ORDER BY id ASC LIMIT 1" - ), - {"fias": house_fias_id}, - ) - .mappings() - .first() - ) - if row: - house_id = int(row["id"]) - _upsert_house_source( - db, - house_id=house_id, - ext_source=ext_source, - ext_id=ext_id, - method="fias_exact", - confidence=0.95, - ) - _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source) - logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id) - return (house_id, 0.95, "fias_exact") + # Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было + # некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих + # такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из + # 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly. # Tier 1: source+ext_id already registered in house_sources row = ( diff --git a/tradein-mvp/backend/app/services/payments/__init__.py b/tradein-mvp/backend/app/services/payments/__init__.py new file mode 100644 index 00000000..1f537849 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/__init__.py @@ -0,0 +1,17 @@ +"""Т-Банк интернет-эквайринг — чистый интеграционный слой (PR-C). + +Модули здесь НЕ импортируют `app.core.config` и не пишут в БД: все секреты +(`terminal_key`, `password`, `base_url`) принимаются аргументами функций/ +конструктора. Причина — параллельный PR-B вводит эти поля в `config.py`, +а проводку (роутер, `_PUBLIC_PATHS`, `payments`-таблицы, статус-машина) +делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо) +§3/§9 для полной схемы разбивки. + +- `token.py` — подпись `Token` запросов + проверка подписи нотификаций. +- `receipt.py` — сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги. +- `tbank_client.py` — httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`. + +Docs: https://developer.tbank.ru/eacq/intro +""" + +from __future__ import annotations diff --git a/tradein-mvp/backend/app/services/payments/receipt.py b/tradein-mvp/backend/app/services/payments/receipt.py new file mode 100644 index 00000000..e1970dd7 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/receipt.py @@ -0,0 +1,143 @@ +"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга. + +Продукт продаёт УСЛУГУ (не товар) — везде фиксированы `PaymentObject="service"` +и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу, +без предоплат/кредита/частичных расчётов). + +Схема (`Receipt` в `Init`, ФФД 1.05) — источник, снят живым запросом +2026-08-06: https://developer.tbank.ru/eacq/api/init + +- `Email` ИЛИ `Phone` — обязательно хотя бы одно (перекрёстный required). +- `Taxation` — обязателен: `osn|usn_income|usn_income_outcome|esn|patent`. +- `Items[].Name` — <=128 символов, обязателен. +- `Items[].Price`/`Quantity`/`Amount` — числа, В КОПЕЙКАХ; `Amount` — это + произведение `Price * Quantity` (дословно из API-reference). +- `Items[].Tax` — ставка НДС. Актуальный список 2026 (Init API reference): + `none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`. + `vat20`/`vat120` В СПИСКЕ НЕТ — сняты, не использовать (см. recon §6/§11 + в `mera-tbank-acquiring-recon.md`, корень репо). + +ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые +вложенные `dict`/`list` из подписи) — это архитектурно гарантировано самой +функцией `token.sign`, а не соглашением здесь. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +TaxRate = Literal[ + "none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122" +] + +Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"] + +_ALLOWED_TAX_RATES: frozenset[str] = frozenset( + {"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"} +) +_ALLOWED_TAXATION: frozenset[str] = frozenset( + {"osn", "usn_income", "usn_income_outcome", "esn", "patent"} +) + +_MAX_ITEM_NAME_LEN = 128 +_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference) + + +class ReceiptBuildError(ValueError): + """Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка.""" + + +@dataclass(frozen=True, slots=True) +class ReceiptItem: + """Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки.""" + + name: str + price_kopecks: int + quantity: int = 1 + tax: TaxRate = "none" + + @property + def amount_kopecks(self) -> int: + """Items[].Amount = Price * Quantity (дословно из API reference).""" + return self.price_kopecks * self.quantity + + def to_payload(self) -> dict[str, Any]: + if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN: + raise ReceiptBuildError( + f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, " + f"получено {len(self.name)}" + ) + if self.price_kopecks <= 0: + raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)") + if self.quantity <= 0: + raise ReceiptBuildError("Items[].Quantity должен быть > 0") + if self.tax not in _ALLOWED_TAX_RATES: + raise ReceiptBuildError( + f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка " + f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются" + ) + return { + "Name": self.name, + "Price": self.price_kopecks, + "Quantity": self.quantity, + "Amount": self.amount_kopecks, + "Tax": self.tax, + "PaymentMethod": "full_payment", + "PaymentObject": "service", + } + + +def build_receipt( + *, + items: list[ReceiptItem], + taxation: Taxation, + email: str | None = None, + phone: str | None = None, +) -> dict[str, Any]: + """Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции). + + Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется — + `Receipt` строится независимо от `Init`-payload заказа. Сверка — на + вызывающей стороне (`service.py`, следующий PR) через + `receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест + `test_receipt_total_matches_order_amount_invariant` в + `tests/test_payments_receipt.py`, который проверяет именно эту сверку. + """ + if not items: + raise ReceiptBuildError("Receipt.Items не может быть пустым") + if len(items) > _MAX_ITEMS: + raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций") + if taxation not in _ALLOWED_TAXATION: + raise ReceiptBuildError( + f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})" + ) + + email_norm = (email or "").strip() or None + phone_norm = (phone or "").strip() or None + if not email_norm and not phone_norm: + raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)") + + payload: dict[str, Any] = { + "Taxation": taxation, + "Items": [item.to_payload() for item in items], + } + if email_norm: + payload["Email"] = email_norm + if phone_norm: + payload["Phone"] = phone_norm + return payload + + +def receipt_total_kopecks(receipt: dict[str, Any]) -> int: + """Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`.""" + items = receipt.get("Items") + if not isinstance(items, list): + return 0 + total = 0 + for item in items: + if isinstance(item, dict): + amount = item.get("Amount") + if isinstance(amount, int): + total += amount + return total diff --git a/tradein-mvp/backend/app/services/payments/tbank_client.py b/tradein-mvp/backend/app/services/payments/tbank_client.py new file mode 100644 index 00000000..3ba74395 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/tbank_client.py @@ -0,0 +1,249 @@ +"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel). + +Стиль и обработка ошибок — по образцу +`app.services.tgbot.client.TelegramClient`: единственные нужные методы, +не тянем отдельный SDK ради пяти HTTP-вызовов. + +Модуль НЕ импортирует `app.core.config` — все параметры (`terminal_key`, +`password`, `base_url`) передаются в конструктор явно аргументами. +Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`): +параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D. + +Docs: https://developer.tbank.ru/eacq/api + +Ретраи: + - Сетевые ошибки (timeout/connect) и HTTP 5xx — экспоненциальный backoff, + capped на `_MAX_BACKOFF_S`. + - Любая 4xx — НЕ ретраится (запрос некорректен / права не те — повтор + транспортного вызова не поможет), сразу `TBankApiError`. + - Бизнес-отказ (HTTP 200, но `Success: false` в теле) — тоже НЕ + ретраится: это содержательный ответ банка, а не сбой транспорта. + +БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*` — +логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details` +из ответа банка. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import httpx + +from app.services.payments.token import sign + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT_S = 15.0 +_MAX_BACKOFF_S = 30.0 +_DEFAULT_MAX_RETRIES = 3 + +DEFAULT_BASE_URL = "https://securepay.tinkoff.ru" + + +class TBankApiError(Exception): + """T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`).""" + + def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None: + self.method = method + self.error_code = error_code + self.message = message + self.details = details + text = f"T-Bank API {method} failed: [{error_code}] {message}" + if details: + text += f" — {details}" + super().__init__(text) + + +def _error_from_body(response: httpx.Response) -> tuple[str, str, str]: + """Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус.""" + try: + data = response.json() + except ValueError: + return str(response.status_code), (response.text or "")[:200], "" + if not isinstance(data, dict): + return str(response.status_code), str(data)[:200], "" + error_code = str(data.get("ErrorCode", response.status_code)) + message = str(data.get("Message", "")) + details = str(data.get("Details", "")) + return error_code, message, details + + +class TBankClient: + """Клиент Т-Банк эквайринга на `httpx.AsyncClient`. + + Каждый вызов — отдельное короткоживущее соединение (без общего + connection-pool между вызовами; частота вызовов в checkout-потоке + низкая, держать долгоживущий клиент не нужно — тот же паттерн, что + `TelegramClient`). + """ + + def __init__( + self, + *, + terminal_key: str, + password: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = _DEFAULT_TIMEOUT_S, + ) -> None: + self._terminal_key = terminal_key + self._password = password + self._base = f"{base_url.rstrip('/')}/v2" + self._timeout = timeout + + def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + """Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит.""" + body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload} + body["Token"] = sign(body, self._password) + return body + + async def _request( + self, + method: str, + payload: dict[str, Any], + *, + max_retries: int = _DEFAULT_MAX_RETRIES, + ) -> dict[str, Any]: + """POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу.""" + body = self._signed_payload(payload) + url = f"{self._base}/{method}" + attempt = 0 + + while True: + attempt += 1 + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post(url, json=body) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + if attempt > max_retries: + logger.error( + "tbank client: %s — network error после %d попыток: %s", + method, + attempt, + exc, + ) + raise TBankApiError(method, "network_error", str(exc)) from exc + backoff = min(2.0**attempt, _MAX_BACKOFF_S) + logger.warning( + "tbank client: %s — network error (попытка %d/%d) — retry через %.0fs", + method, + attempt, + max_retries, + backoff, + ) + await asyncio.sleep(backoff) + continue + + if response.status_code >= 500: + if attempt > max_retries: + error_code, message, details = _error_from_body(response) + logger.error( + "tbank client: %s — HTTP %d после %d попыток, сдаёмся", + method, + response.status_code, + attempt, + ) + raise TBankApiError(method, error_code, message, details) + backoff = min(2.0**attempt, _MAX_BACKOFF_S) + logger.warning( + "tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs", + method, + response.status_code, + attempt, + max_retries, + backoff, + ) + await asyncio.sleep(backoff) + continue + + if response.status_code >= 400: + # 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет. + error_code, message, details = _error_from_body(response) + raise TBankApiError(method, error_code, message, details) + + try: + data = response.json() + except ValueError as exc: + raise TBankApiError(method, "invalid_json", str(exc)) from exc + + if not isinstance(data, dict): + raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект") + + if not data.get("Success"): + error_code = str(data.get("ErrorCode", response.status_code)) + message = str(data.get("Message", "")) + details = str(data.get("Details", "")) + raise TBankApiError(method, error_code, message, details) + + return data + + async def init_payment( + self, + *, + order_id: str, + amount_kopecks: int, + description: str = "", + notification_url: str | None = None, + success_url: str | None = None, + fail_url: str | None = None, + receipt: dict[str, Any] | None = None, + pay_type: str | None = None, + data: dict[str, str] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`.""" + payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks} + if description: + payload["Description"] = description + if notification_url: + payload["NotificationURL"] = notification_url + if success_url: + payload["SuccessURL"] = success_url + if fail_url: + payload["FailURL"] = fail_url + if receipt: + payload["Receipt"] = receipt + if pay_type: + payload["PayType"] = pay_type + if data: + payload["DATA"] = data + return await self._request("Init", payload) + + async def get_state(self, *, payment_id: str) -> dict[str, Any]: + """`POST /v2/GetState` — статус платежа по `PaymentId`.""" + return await self._request("GetState", {"PaymentId": payment_id}) + + async def check_order(self, *, order_id: str) -> dict[str, Any]: + """`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции).""" + return await self._request("CheckOrder", {"OrderId": order_id}) + + async def confirm( + self, + *, + payment_id: str, + amount_kopecks: int | None = None, + receipt: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`).""" + payload: dict[str, Any] = {"PaymentId": payment_id} + if amount_kopecks is not None: + payload["Amount"] = amount_kopecks + if receipt: + payload["Receipt"] = receipt + return await self._request("Confirm", payload) + + async def cancel( + self, + *, + payment_id: str, + amount_kopecks: int | None = None, + receipt: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан).""" + payload: dict[str, Any] = {"PaymentId": payment_id} + if amount_kopecks is not None: + payload["Amount"] = amount_kopecks + if receipt: + payload["Receipt"] = receipt + return await self._request("Cancel", payload) diff --git a/tradein-mvp/backend/app/services/payments/token.py b/tradein-mvp/backend/app/services/payments/token.py new file mode 100644 index 00000000..80f68a5b --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/token.py @@ -0,0 +1,98 @@ +"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций. + +Docs (проверено живым запросом к doc-порталу, 2026-08-06): +- https://developer.tbank.ru/eacq/intro/developer/token — формирование Token. +- https://developer.tbank.ru/eacq/intro/developer/notification + (раздел «Проверить токен уведомлений») — тот же алгоритм для входящих + нотификаций. + +Алгоритм (идентичен для исходящего запроса и для проверки нотификации): + +1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем + `None`, исключаем значения-`dict`/`list` (документация формулирует это + как «кроме параметра Token и вложенных объектов (Data, Receipt)» — + здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые + вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items` + или `Shops`, отсекаются одинаково, потому что все они не примитивы). +2. `bool` → `"true"`/`"false"` (нижний регистр); `int`/`float` → строка без + экспоненциальной записи; `str` — как есть. +3. Добавляем пару `Password: <пароль_терминала>`. +4. Сортируем пары по имени ключа (лексикографически по строке ключа), + конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку. +5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре. + +Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с +doc-портала — оба подтверждены живым запросом, не выдуманы. +""" + +from __future__ import annotations + +import hashlib +import hmac +from typing import Any + +_EXCLUDED_KEYS = frozenset({"Token"}) + + +def _stringify_value(value: bool | int | float | str) -> str: + """Приводит плоское значение к строке по правилам Т-Банка. + + `bool` проверяем ДО `int`: в Python `bool` — подкласс `int` + (`isinstance(True, int) is True`), поэтому порядок веток важен — + иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + # `format(..., "f")` — фиксированная нотация, Python никогда не + # добавляет экспоненту при presentation type 'f' (в отличие от + # str()/repr(), которые для очень больших/малых float дают "1e+21"). + text = format(value, "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text + return str(value) + + +def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]: + """Плоские поля payload, готовые к конкатенации: без Token/None/dict/list.""" + result: dict[str, str] = {} + for key, value in payload.items(): + if key in _EXCLUDED_KEYS or value is None: + continue + if isinstance(value, dict | list): + continue + result[key] = _stringify_value(value) + return result + + +def sign(payload: dict[str, Any], password: str) -> str: + """Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...). + + `payload` — тело запроса ДО добавления поля `Token` (поле `Password` + самому передавать не нужно — функция добавляет его сама и удаляет + участие любых вложенных объектов автоматически). + """ + fields = _flatten_signable_fields(payload) + fields["Password"] = password + raw = "".join(fields[key] for key in sorted(fields)) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def verify_notification_token(payload: dict[str, Any], password: str) -> bool: + """Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`. + + `payload` — полное тело нотификации, включая присланный `Token` (сам + алгоритм сборки исключает ключ `Token` из подписи — см. `_EXCLUDED_KEYS`). + + Возвращает `False`, если в payload нет строкового непустого `Token` + (нечего сравнивать) — вызывающая сторона обязана трактовать это как + отказ в обработке нотификации, а не как «пропустить проверку». + """ + received_token = payload.get("Token") + if not isinstance(received_token, str) or not received_token: + return False + expected_token = sign(payload, password) + return hmac.compare_digest(expected_token, received_token) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 4165571a..438e1eeb 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -21,8 +21,10 @@ from __future__ import annotations import asyncio import logging +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any +from scraper_kit.orchestration import runs as kit_runs from scraper_kit.orchestration.scheduler import ( Handler, reschedule_after_minutes, @@ -39,39 +41,97 @@ logger = logging.getLogger(__name__) # ── cian_history_backfill — cookie-gated backfill ──────────────────────────── +# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки +# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу +# видно, встал ли сбор из-за кук или из-за конкурентного прогона. +SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing" +SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired" +SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid" + + +def _alert_cian_cookies(source: str, detail: str) -> None: + """Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning). + + В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR) + (scheduler_main.py) — ERROR-запись сама становится событием, а прежний + `capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в + недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в + docker-логах и в строке scrape_runs, которая переживает редеплой. + """ + logger.error( + "scheduler: %s пропущен — %s. Перезалейте куки Циана через админку " + "(до этого backfill истории стоит)", + source, + detail, + ) + + async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool: """Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522). - Cookies отсутствуют/протухли → defer next_run_at на следующее окно и skip - (иначе get_due_schedules переотбирает schedule каждые 60с и verify_session - долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run. - """ - import sentry_sdk + Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной, + двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает + schedule каждые 60с и verify_session долбит Cian круглосуточно). - from app.services.cian_session import load_session, verify_session + #2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в + docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения + last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт + стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках + исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW() + и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки + ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас. + """ + from app.services.cian_session import ( + COOKIE_EXPIRY_WARN_DAYS, + load_session, + session_expires_at, + verify_session, + ) + + source: str = schedule_row["source"] + now = datetime.now(tz=UTC) cookies = load_session(db) if cookies is None: - logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB") + expires_at = session_expires_at(db) + if expires_at is None: + reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД" + elif expires_at <= now: + reason = SKIP_CIAN_COOKIES_EXPIRED + detail = ( + f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)" + ) + else: + reason = SKIP_CIAN_COOKIES_INVALID + detail = "куки Циана помечены невалидными (last_invalid_at)" + _alert_cian_cookies(source, detail) + kit_runs.mark_skipped(db, source=source, reason=reason, details=detail) kit_defer_next_run_at(db, schedule_row) return False state = await verify_session(cookies) if state is None: - logger.warning( - "scheduler: cian_history_backfill — cookies expired or invalid, skipping run" - ) - try: - sentry_sdk.capture_message( - "cian_history_backfill skipped: Cian session cookies expired — " - "please re-upload via admin UI", - level="warning", - ) - except Exception: - pass # sentry_sdk not initialised in dev + # verify вернул именно None (401 / isAuthenticated=false) — куки числятся + # валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник + # недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session. + detail = "Циан не принимает куки (разлогин)" + _alert_cian_cookies(source, detail) + kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail) kit_defer_next_run_at(db, schedule_row) return False + # Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора. + # valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких + # аккаунтах свежайшая-любая может быть чужой протухшей строкой). + expires_at = session_expires_at(db, valid_only=True) + if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS): + logger.error( + "scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, " + "иначе %s встанет молча", + expires_at.date().isoformat(), + (expires_at - now).total_seconds() / 86400, + source, + ) return True @@ -94,13 +154,15 @@ async def _job_rosreestr_dkp( # ── listing_source_snapshot — sync DB-snapshot в executor ──────────────────── +# params прокинуты (#2607) — snapshot_listing_sources теперь читает budget_sec из +# default_params (SET LOCAL statement_timeout, см. app/tasks/listing_source_snapshot.py). async def _job_listing_source_snapshot( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext ) -> None: from app.tasks.listing_source_snapshot import snapshot_listing_sources loop = asyncio.get_event_loop() - await loop.run_in_executor(None, snapshot_listing_sources, db, run_id) + await loop.run_in_executor(None, snapshot_listing_sources, db, run_id, params) # ── asking_to_sold_ratio_refresh — sync re-derive в executor ───────────────── @@ -113,6 +175,16 @@ async def _job_asking_to_sold_ratio( await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id) +# ── deal_city_price_bands_refresh — sync tier-aware re-derive в executor ────── +async def _job_deal_city_price_bands_refresh( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + from app.tasks.deal_city_price_bands_refresh import refresh_deal_city_price_bands + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, refresh_deal_city_price_bands, db, run_id) + + # ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ──── async def _job_refresh_search_matview( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -144,12 +216,19 @@ async def _job_deactivate_stale( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext ) -> None: from app.core.config import settings as _settings - from app.tasks.deactivate_stale_avito import deactivate_stale_listings + from app.tasks.deactivate_stale_avito import ( + DEFAULT_MIN_CONFIRMATIONS, + deactivate_stale_listings, + ) listing_source: str = params.get("listing_source", "avito") ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days) segments: list[str] | None = params.get("segments") staleness_column: str = params.get("staleness_column", "last_seen_at") + # Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание + # получает страховочный порог, а не «деактивируй вслепую». Посчитанные по + # источнику пороги приходят из default_params (миграция 219). + min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS) loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -161,6 +240,7 @@ async def _job_deactivate_stale( ttl_days=ttl_days, segments=segments, staleness_column=staleness_column, + min_confirmations=min_confirmations, ), ) @@ -329,17 +409,38 @@ async def _job_house_imv_backfill( only_status=only_status, heartbeat=_heartbeat, ) - ctx.runs.mark_done( - db, - run_id, - { - "checked": result.checked, - "saved": result.saved, - "skipped": result.skipped, - "errors": result.errors, - "duration_sec": int(result.duration_sec), - }, - ) + counters = { + "checked": result.checked, + "saved": result.saved, + "skipped": result.skipped, + "errors": result.errors, + "duration_sec": int(result.duration_sec), + # #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из + # ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного + # из них тут не было, поэтому все 39 прогонов этого source лежат в БД + # с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд + # done с нулевым результатом» (#2625) — то есть даже идеальный прогон + # с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы + # ложной тревогой про капчу. + # Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст + # алерт — но пустая очередь при ежедневном расписании это и правда сигнал. + "total_seen": result.checked, + "new_count": result.saved, + } + # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это + # «сделали то, что собирались», а не «не поймали известное исключение». + # На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50. + # Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная + # пустота, она по-прежнему done. + if result.saved == 0 and result.errors > 0: + ctx.runs.mark_failed( + db, + run_id, + f"saved=0 при errors={result.errors} (checked={result.checked})", + counters, + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception as exc: logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id) try: @@ -348,6 +449,57 @@ async def _job_house_imv_backfill( logger.exception("scheduler: mark_failed crashed run_id=%d", run_id) +# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ───────── +# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py) +# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник +# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех). +# Это не мёртвый код, а оборванная проводка: нечему было его вызвать. +async def _job_domrf_kapremont_load( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + """Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings. + + Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не + дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у + _job_yandex_newbuilding_sweep. + + Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные + колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых + прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же + промах, что чинили у house_imv_backfill). + """ + from app.services.domrf_kapremont_loader import ( + backfill_houses_from_domrf, + load_domrf_kapremont, + propagate_listings_year_from_houses, + ) + + def _run() -> dict[str, int]: + load_counts = load_domrf_kapremont(db) + db.commit() + houses_counts = backfill_houses_from_domrf(db) + listings_counts = propagate_listings_year_from_houses(db) + db.commit() + return { + "kr11_rows": load_counts["kr11_rows"], + "upserted": load_counts["upserted"], + "houses_updated": houses_counts["houses_updated"], + "listings_updated": listings_counts["listings_updated"], + # см. докстринг: выделенные колонки прогона + гейт «нулевой прогон». + "total_seen": load_counts["kr11_rows"], + "new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"], + } + + loop = asyncio.get_event_loop() + try: + counters = await loop.run_in_executor(None, _run) + ctx.runs.mark_done(db, run_id, counters) + except Exception as exc: + logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id) + db.rollback() + ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {}) + + # ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ─────────────── async def _job_purge_expired_trade_in_data( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -400,8 +552,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: """Реестр НЕ-sweep продуктовых source→Handler для kit build_registry. Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь — - их даёт build_registry(_default_kit_handlers). Здесь — 18 именованных + 1 wildcard + их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard (deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch. + (Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.) `ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается. @@ -417,6 +570,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "asking_to_sold_ratio_refresh": Handler( _job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh" ), + "deal_city_price_bands_refresh": Handler( + _job_deal_city_price_bands_refresh, "deal_city_price_bands_refresh" + ), "refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"), "yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"), "sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"), @@ -442,6 +598,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"), "house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"), "house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"), + "domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"), "purge_expired_trade_in_data": Handler( _job_purge_expired_trade_in_data, "purge_expired_trade_in_data" ), diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 44b666ee..0e7e503d 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -15,11 +15,68 @@ ipify-пробу через каждый прокси и обновляет heal за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую). Health: - - mark_health(ok=True) → consecutive_fails=0, last_ok_at/last_check_at, exit_ip, latency. + - mark_health(ok=True) → consecutive_fails=0, enabled=true, last_ok_at/last_check_at, + exit_ip, latency. enabled=true — реанимация: узел, выключенный + ранее авто-disable'ом, возвращается в строй первой же успешной + пробой (см. run_proxy_healthcheck). - mark_health(ok=False) → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси авто-disable (enabled=false), чтобы битый узел выпал из пула. - acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS. +Self-healing (#2600): + - run_proxy_healthcheck проверяет не только enabled-узлы, но и disabled — реже, раз в + DISABLED_RECHECK_MINUTES (или если ни разу не проверялся). Успешная проба выключенного + узла реанимирует его (enabled=true), инкрементит счётчик `revived` и пишет INFO-лог. + Без этого auto-disable необратим: транзиентный сбой = вечный приговор узлу. + - acquire, не найдя свободного здорового узла нужной provider_affinity, вторым заходом + берёт любой свободный здоровый узел ЛЮБОЙ affinity (WARNING-лог) — иначе источник + голодает при живых свободных узлах чужой affinity. Fallback НЕ забирает последний + enabled-узел выделенной affinity (пример — domclick, один узел на всё, см. acquire + docstring) — иначе чинили бы один источник ценой полной поломки другого. + +Бан по паре «узел × источник» (#2600 п.2, таблица scrape_proxy_source_bans, миграция 210): + - Авито банит IP, Яндекс через тот же IP ходит чисто. Поэтому распознанный бан + площадкой (`mark_banned`) НЕ выключает узел глобально (так делал #2600 п.1), а + пишет строку (proxy_id, source, banned_until) — `acquire(source)` перестаёт + выдавать узел ЭТОМУ источнику, для остальных узел остаётся первосортным. + - Отличие от `enabled=false`: глобальное выключение — это либо решение оператора + (disabled_reason НЕ NULL, #2610), либо авто-disable по серии ТРАНСПОРТНЫХ сбоев + (mark_health, DISABLE_THRESHOLD). Бан площадкой — свойство ПАРЫ, а не узла, и + снимается сам по времени, без ручного PATCH и без ipify-пробы (ipify площадку не + эмулирует, бана не видит — ровно тот баг, из-за которого п.1 требовал ручного + вмешательства). + - Срок эскалирует на повторных банах той же пары: SOURCE_BAN_BASE_HOURS * + 2^(ban_count-1), но не больше SOURCE_BAN_MAX_HOURS. Истёкшие строки сносятся + purge'ем в run_proxy_healthcheck только через SOURCE_BAN_PURGE_DAYS — это же и + механизм сброса ban_count (см. комментарий у purge, НЕ «оптимизировать»). + - Защита последнего узла сохранена, но теперь ПО ИСТОЧНИКУ: если после записи бана + у acquire(source) не останется ни одного кандидата — бан не пишется, только + WARNING (пул надо пополнять, #2638). + - Ручное снятие — `clear_source_bans` (ложный бан детектора капчи, #2642) плюс + автоматическое после успешной ротации exit-IP: бан привязан к proxy_id, а банился + IP, поэтому смена адреса делает строку недействительной. + +Ручное выключение vs авто-выключение (#2610): + - scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины + enabled=false: пул выключил сам после серии сбоев (disabled_reason IS NULL) — + воскрешается первой же успешной пробой, как задумано #2609; оператор выключил + руками через admin API (disabled_reason НЕ NULL) — mark_health(ok=True) НЕ + трогает enabled, пишет WARNING с id узла и причиной. Без этого узел, снятый + оператором из ротации (например забаненный площадкой — ipify через него всё + равно отвечает 200), возвращался бы в строй первой же health-пробой молча. + - Сброс флага (возврат к авто-восстанавливаемому состоянию) — только через + admin API PATCH /proxies/{id} enabled=true (app/api/v1/admin.py:patch_proxy), + который явно обнуляет disabled_reason в NULL. + +Sticky session lease (browser-путь, живая регрессия 2026-08): + - `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии + (весь прогон), а не на каждый `/fetch` — иначе при N>=2 живых узлах пула каждый + /fetch получал ДРУГОЙ прокси (acquire сортирует по last_ok_at) и camoufox + релончился на каждый запрос (server.py: relaunch только при реальной смене + желаемого прокси). См. `touch()` — heartbeat, которым сессия продлевает leased_at + на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового + прогона. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -36,16 +93,23 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) __all__ = [ + "DISABLED_RECHECK_MINUTES", "DISABLE_THRESHOLD", "MAX_CONSECUTIVE_FAILS", "NON_RUN_LEASE_MARKER", + "SOURCE_BAN_BASE_HOURS", + "SOURCE_BAN_MAX_HOURS", + "SOURCE_BAN_PURGE_DAYS", "STALE_LEASE_MINUTES", "ProxyLease", "acquire", + "clear_source_bans", + "mark_banned", "mark_health", "reap_stale_leases", "release", "run_proxy_healthcheck", + "touch", ] # ── Пороги ─────────────────────────────────────────────────────────────────── @@ -62,14 +126,42 @@ DISABLE_THRESHOLD = 5 # освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом. STALE_LEASE_MINUTES = 30 +# Disabled-узлы перепроверяются не каждый прогон (это долбёж по мёртвому/дорогому +# провайдеру), а раз в это число минут — либо если ни разу не проверялся. Успешная +# проба реанимирует узел (см. run_proxy_healthcheck). Без recheck'а auto-disable +# необратим: транзиентный сбой = вечный приговор (#2600). +DISABLED_RECHECK_MINUTES = 60 + # Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs). NON_RUN_LEASE_MARKER = -1 +# ── Бан по паре «узел × источник» (#2600 п.2) ──────────────────────────────── +# Срок ПЕРВОГО бана пары (proxy_id, source). 6 часов — эмпирический компромисс: +# площадки снимают IP-баны обычно за часы, а не минуты (короче — вернём узел под тот +# же бан и потратим прогон впустую), но и не сутки (узел дефицитный, #2638). +SOURCE_BAN_BASE_HOURS = 6 + +# Потолок эскалации: SOURCE_BAN_BASE_HOURS * 2^(ban_count-1) обрезается этим значением +# (6 → 12 → 24 → 48 → 72 → 72 …). Дольше 3 суток держать бесполезно: либо площадка +# сняла бан, либо узел мёртв насовсем и его должен вычистить оператор. +SOURCE_BAN_MAX_HOURS = 72 + +# Через столько суток ПОСЛЕ истечения бана строка сносится purge'ем (см. +# run_proxy_healthcheck). Это же и сброс ban_count — см. комментарий там. +SOURCE_BAN_PURGE_DAYS = 7 + # URL для health-пробы: возвращает exit-IP JSON'ом. Тот же эндпоинт, что и admin # /scraper/health (_probe_current_ip). _HEALTH_PROBE_URL = "https://api.ipify.org" _HEALTH_PROBE_TIMEOUT_S = 10.0 +# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для +# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что +# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не +# столкнуться с advisory-локами других частей системы, которые тоже могут +# использовать pg_advisory_lock с мелкими/предсказуемыми ключами. +_MARK_BANNED_ADVISORY_LOCK_KEY = 0x2600_BA22 # "2600 BAn" — мнемоника, не magic + @dataclass class ProxyLease: @@ -90,10 +182,30 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe (last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или NON_RUN_LEASE_MARKER если run_id не задан) и коммитит. + Если свободных здоровых узлов нужной affinity (provider/'any') нет — вторым заходом + берётся любой свободный здоровый узел ЛЮБОЙ affinity (тот же ORDER BY/FOR UPDATE SKIP + LOCKED), с WARNING-логом. Приоритет не меняется: своя affinity всегда предпочтительнее, + чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах + чужой affinity (#2600). + + Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см. + 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1), + намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого + одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex, + domclick останется без прокси вообще — хуже, чем голодание исходного источника, + которое фикс призван устранить. Кандидат участвует в fallback, только если его + affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) — + т.е. выдача не обнулит доступность выделенной affinity целиком. + + ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у + (scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито, + остаётся полноценным кандидатом для Яндекса и остальных источников. Бан по чужому + source на выдачу не влияет вообще. + Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную другим вызовом строку, второй параллельный acquire берёт следующую свободную. - Returns ProxyLease или None если свободных здоровых прокси нет. + Returns ProxyLease или None если свободных здоровых прокси нет вообще. """ lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER @@ -107,6 +219,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe AND consecutive_fails < CAST(:max_fails AS integer) AND provider_affinity IN (:provider, 'any') AND leased_by IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b + WHERE b.proxy_id = scrape_proxies.id + AND b.source = :provider + AND b.banned_until > now() + ) ORDER BY last_ok_at NULLS LAST, id FOR UPDATE SKIP LOCKED LIMIT 1 @@ -117,6 +236,65 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe .mappings() .fetchone() ) + + fallback_used = False + if row is None: + # Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел + # ЛЮБОЙ affinity, кроме последнего enabled-узла выделенной affinity (domclick и + # т.п.) — EXISTS-подзапрос требует хотя бы ОДИН ДРУГОЙ enabled-узел той же + # affinity, иначе affinity='any' достаточно. + row = ( + db.execute( + text( + """ + SELECT sp.id, sp.url, sp.kind, sp.rotate_url + FROM scrape_proxies AS sp + WHERE sp.enabled + AND sp.consecutive_fails < CAST(:max_fails AS integer) + AND sp.leased_by IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b + WHERE b.proxy_id = sp.id + AND b.source = :provider + AND b.banned_until > now() + ) + AND ( + sp.provider_affinity = 'any' + -- backup обязан быть ПРИГОДЕН для своей affinity, а не просто + -- enabled (#2600 п.2 deep-review): после перехода на per-source + -- баны узел бывает enabled и одновременно забанен СВОИМ же + -- источником. Засчитывать такой как backup — значит разрешить + -- fallback увести последний реально рабочий узел выделенной + -- affinity и обрушить её (два domclick-узла, один забанен + -- domclick'ом → второй уходит под avito → domclick без прокси). + OR EXISTS ( + SELECT 1 + FROM scrape_proxies AS other + WHERE other.provider_affinity = sp.provider_affinity + AND other.enabled + AND other.id <> sp.id + AND NOT EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b2 + WHERE b2.proxy_id = other.id + AND b2.source = other.provider_affinity + AND b2.banned_until > now() + ) + ) + ) + ORDER BY sp.last_ok_at NULLS LAST, sp.id + FOR UPDATE SKIP LOCKED + LIMIT 1 + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "provider": provider}, + ) + .mappings() + .fetchone() + ) + fallback_used = row is not None + if row is None: db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто) return None @@ -133,9 +311,18 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe {"run_id": lease_marker, "id": proxy_id}, ) db.commit() - logger.info( - "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker - ) + if fallback_used: + logger.warning( + "proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity " + "(no free healthy proxy of matching affinity, issuing proxy of other affinity)", + proxy_id, + provider, + lease_marker, + ) + else: + logger.info( + "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker + ) return ProxyLease( id=proxy_id, url=str(row["url"]), @@ -160,6 +347,47 @@ def release(db: Session, proxy_id: int) -> None: logger.info("proxy_pool: released proxy id=%d", proxy_id) +def touch(db: Session, proxy_id: int) -> None: + """Heartbeat: продлить lease (leased_at=now()) без трогания health-полей. + + #2164 P4 sticky-session fix (2026-08). + + Раньше `BrowserFetcher` брал/отпускал прокси на КАЖДЫЙ `/fetch` — при N>=2 живых узлах + это гарантированно меняло прокси между соседними запросами (`acquire` сортирует ORDER + BY last_ok_at NULLS LAST, id — «давно не использованный первый») и гоняло camoufox + relaunch на каждый /fetch (см. server.py `_ensure_browser` — relaunch только при + реальной смене желаемого прокси). Фикс: один lease на весь жизненный цикл + `BrowserFetcher` (весь прогон, часы). Но `reap_stale_leases` освобождает lease старше + `STALE_LEASE_MINUTES` (=30) — прогон ДОЛЬШЕ 30 минут (полная загрузка Циана шла часами) + остался бы без прокси на середине, а второй consumer мог бы получить тот же прокси. + + Решение: НЕ увеличивать `STALE_LEASE_MINUTES` (это притупило бы реальную задачу + reaper'а — освобождать lease мёртвого/зависшего run'а, который никогда не вызовет + release). Вместо этого `BrowserFetcher` вызывает `touch` на каждый /fetch (успешный + ИЛИ неуспешный — сам факт завершённого запроса доказывает, что процесс жив и активно + использует прокси) — `leased_at` подтверждается заново, окно `STALE_LEASE_MINUTES` + сдвигается вперёд, пока идёт трафик. Реальный мёртвый/зависший run (упал/завис БЕЗ + единого /fetch дольше 30 минут) по-прежнему реапится штатно — семантика reaper'а не + ослаблена, просто измеряется от «последней активности», а не от «момента acquire». + + No-op (0 rows), если прокси уже не арендован (leased_by IS NULL, например reaper + успел отобрать в гонке) — defensive, вызывающий код (BrowserFetcher) не должен падать. + """ + db.execute( + text( + """ + UPDATE scrape_proxies + SET leased_at = now() + WHERE id = CAST(:id AS bigint) + AND leased_by IS NOT NULL + """ + ), + {"id": proxy_id}, + ) + db.commit() + logger.debug("proxy_pool: touch (heartbeat) proxy id=%d", proxy_id) + + def mark_health( db: Session, proxy_id: int, @@ -167,30 +395,63 @@ def mark_health( *, exit_ip: str | None = None, latency_ms: int | None = None, + fail_kind: str | None = None, ) -> None: """Записать результат health-check'а прокси. ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/ - exit_ip/latency_ms. + exit_ip/latency_ms. enabled=true — РЕАНИМАЦИЯ, но ТОЛЬКО если узел не + выключен вручную (disabled_reason IS NULL, #2610): узел, ранее выключенный + auto-disable'ом (disabled_reason IS NULL), возвращается в строй первой же + успешной пробой, как задумано #2609 п.1. Узел, выключенный оператором + (disabled_reason НЕ NULL), остаётся enabled=false — иначе снятый с ротации + забаненный площадкой узел воскрешался бы первой же ipify-пробой (ipify + площадку не эмулирует, значит бан ею не ловится). Этот случай логируется + WARNING'ом — раньше (до #2610) происходил молча. ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси - авто-disable (enabled=false). last_check_at обновляется в любом случае. + авто-disable (enabled=false, disabled_reason НЕ трогается — узел уходит в + disable БЕЗ причины, т.е. остаётся авто-воскрешаемым). last_check_at + обновляется в любом случае. + + fail_kind — необязательная классификация неуспеха ("timeout" / "connect_error" / + "http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования. + Счётчик consecutive_fails/порог disable инкрементится одинаково для любого fail_kind — + аккуратное разделение "транзиентный сбой vs перманентный бан" (разные пороги/скорость + инкремента по типу ошибки) требует более глубокой переработки модуля (отдельный + трекинг по типам ошибок, вероятно per-fail_kind счётчики) и намеренно НЕ сделано в + рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее. """ if ok: - db.execute( - text( - """ - UPDATE scrape_proxies - SET consecutive_fails = 0, - last_ok_at = now(), - last_check_at = now(), - exit_ip = CAST(:exit_ip AS text), - latency_ms = CAST(:latency_ms AS integer), - updated_at = now() - WHERE id = CAST(:id AS bigint) - """ - ), - {"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id}, + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies + SET consecutive_fails = 0, + last_ok_at = now(), + last_check_at = now(), + exit_ip = CAST(:exit_ip AS text), + latency_ms = CAST(:latency_ms AS integer), + enabled = CASE + WHEN disabled_reason IS NULL THEN true ELSE enabled + END, + updated_at = now() + WHERE id = CAST(:id AS bigint) + RETURNING disabled_reason + """ + ), + {"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id}, + ) + .mappings() + .fetchone() ) + if row is not None and row["disabled_reason"] is not None: + logger.warning( + "proxy_pool: mark_health id=%d ok=True but stays disabled — manually " + "disabled (reason=%r), auto-revive skipped (#2610)", + proxy_id, + row["disabled_reason"], + ) else: # consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла). db.execute( @@ -210,7 +471,245 @@ def mark_health( {"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id}, ) db.commit() - logger.info("proxy_pool: mark_health id=%d ok=%s exit_ip=%s", proxy_id, ok, exit_ip) + logger.info( + "proxy_pool: mark_health id=%d ok=%s exit_ip=%s fail_kind=%s", + proxy_id, + ok, + exit_ip, + fail_kind, + ) + + +def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: + """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и + авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — + транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно + распознана вызывающим кодом (валидная HTML-заглушка/капча/QRATOR-маркер — НЕ + исключение транспорта, НЕ голый network-fail). + + ЧТО ИМЕННО ДЕЛАЕТСЯ (изменение против #2600 п.1): узел БОЛЬШЕ НЕ выключается + глобально (`enabled=false, disabled_reason='banned:'` — так было в п.1). + Пишется строка в `scrape_proxy_source_bans` (миграция 210): пока + `banned_until > now()`, `acquire(source)` этот узел не выдаёт, а для ЛЮБОГО + другого источника он остаётся первосортным. Авито банит IP — Яндекс через тот же + IP ходит чисто; глобальное выключение выкидывало живой узел отовсюду и худило пул + в разы быстрее, чем его пополняют (#2638). `enabled`/`disabled_reason` остаются + исключительно за оператором (#2610) и за авто-disable'ом по транспортным сбоям. + + ЭСКАЛАЦИЯ: первый бан пары — SOURCE_BAN_BASE_HOURS; каждый следующий удваивает + срок (ban_count после инкремента N → SOURCE_BAN_BASE_HOURS * 2^(N-1)), но не выше + SOURCE_BAN_MAX_HOURS. Узел, который площадка банит раз за разом, отдыхает от неё + всё дольше, вместо того чтобы жечь прогоны. Сброс ban_count — только purge'ем + истёкших строк (run_proxy_healthcheck, SOURCE_BAN_PURGE_DAYS). + + ЗАЩИТА ПОСЛЕДНЕГО УЗЛА, ТЕПЕРЬ ПО ИСТОЧНИКУ (issue #2600 риск, паттерн #2609): + если после записи бана у `acquire(source)` не останется НИ ОДНОГО кандидата — бан + НЕ пишется, только WARNING. Доступность считается ТЕМ ЖЕ правилом, что и acquire() + (primary affinity ИЛИ 'any' + fallback на чужую affinity, которая не последняя из + своей) ПЛЮС отсутствие активной бан-строки для этого source — EXISTS ниже, а не + наивный `COUNT(*) WHERE enabled`. Голодать без прокси хуже, чем ходить через + забаненный: капча хотя бы иногда пропускает, отсутствие узла — нет. + + `leased_by IS NULL` защита НАМЕРЕННО не проверяет (в отличие от acquire) — так было + и в п.1, и это не оплошность: lease живёт минуты-часы и снимается сам (release / + reap_stale_leases), т.е. занятый узел — это доступный узел через мгновение, а вот + отказ записать бан из-за чужого lease был бы вечным (узел так и остался бы в выдаче + забаненным). Точность здесь не бесплатна: с проверкой lease защита срабатывала бы + ложно при каждом параллельном прогоне. + + КОНКУРЕНТНОСТЬ (deep-review fix 2 из #2600 п.1, сохранено): один + `INSERT ... WHERE EXISTS(...)` — НЕ атомарная гарантия поперёк СТРОК. EXISTS читает + состояние других строк на момент своего снапшота (READ COMMITTED), но не лочит их — + два ПАРАЛЛЕЛЬНЫХ mark_banned для РАЗНЫХ proxy_id (напр. avito банит A, cian банит B + миллисекундами позже) каждый может увидеть другого как "ещё живого" в своём EXISTS и + оба закоммититься → для источника не остаётся ни одного узла разом. Фикс: + `pg_advisory_xact_lock` в начале транзакции сериализует ВСЕ mark_banned-вызовы между + собой (xact-scoped — снимается сам на commit/rollback, leak невозможен). Один + глобальный ключ вместо per-source — сериализует и непересекающиеся баны тоже, но + частота вызовов низкая (несколько банов в час, не hot-path) — цена оправдана + простотой против per-row `SELECT ... FOR UPDATE` по кандидатам (выше риск deadlock + между параллельными mark_banned, лочащими пересекающиеся строки в разном порядке). + ponytail: global advisory lock, не per-source — переходи на составной ключ + (напр. hashtext(source)) если частота банов когда-нибудь станет hot-path. + + Идемпотентно: повторный бан той же пары не создаёт дубль (PK (proxy_id, source)) — + продлевает срок по правилу эскалации. Несуществующий proxy_id — no-op + WARNING. + + Best-effort по контракту вызывающих (`BrowserFetcher.report_ban`, `curl_proxy_url`) — + сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает + (падает как обычно) — caller решает, ловить или нет. + """ + # Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг + # "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции. + db.execute( + text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"), + {"key": _MARK_BANNED_ADVISORY_LOCK_KEY}, + ) + # INSERT ... SELECT ... WHERE EXISTS: guard'ы в WHERE источника строк — не прошли, + # значит строк на вставку нет, конфликта нет, эскалации нет (0 rows → ветка логов ниже). + # LEAST(ban_count, 16) в показателе — страховка от переполнения double при абсурдном + # ban_count (потолок SOURCE_BAN_MAX_HOURS всё равно срежет результат гораздо раньше). + row = ( + db.execute( + text( + """ + INSERT INTO scrape_proxy_source_bans (proxy_id, source, banned_until, reason) + SELECT CAST(:proxy_id AS bigint), + CAST(:source AS text), + now() + make_interval(hours => CAST(:base_hours AS integer)), + CAST(:reason AS text) + WHERE EXISTS ( + SELECT 1 FROM scrape_proxies + WHERE id = CAST(:proxy_id AS bigint) + ) + AND EXISTS ( + SELECT 1 + FROM scrape_proxies sp + WHERE sp.id <> CAST(:proxy_id AS bigint) + AND sp.enabled + AND sp.consecutive_fails < CAST(:max_fails AS integer) + AND NOT EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b + WHERE b.proxy_id = sp.id + AND b.source = CAST(:source AS text) + AND b.banned_until > now() + ) + AND ( + sp.provider_affinity IN (:source, 'any') + -- other.id <> sp.id (а не NOT IN (sp.id, :proxy_id), как в + -- п.1): банимый узел остаётся enabled и по-прежнему обслуживает + -- СВОЮ affinity — значит он и есть валидный backup для неё. + -- NOT EXISTS b2 — тот же критерий пригодности, что в acquire() + -- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом + -- не считается (иначе защита сочла бы affinity живой, когда она + -- уже нет). + OR EXISTS ( + SELECT 1 + FROM scrape_proxies other + WHERE other.provider_affinity = sp.provider_affinity + AND other.enabled + AND other.id <> sp.id + AND NOT EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b2 + WHERE b2.proxy_id = other.id + AND b2.source = other.provider_affinity + AND b2.banned_until > now() + ) + ) + ) + ) + ON CONFLICT (proxy_id, source) DO UPDATE + SET ban_count = scrape_proxy_source_bans.ban_count + 1, + banned_until = now() + make_interval(hours => CAST( + LEAST( + CAST(:base_hours AS integer) + * power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)), + CAST(:max_hours AS integer) + ) AS integer)), + reason = CAST(:reason AS text), + updated_at = now() + RETURNING ban_count, banned_until + """ + ), + { + "proxy_id": proxy_id, + "source": source, + "reason": f"banned:{source}", + "base_hours": SOURCE_BAN_BASE_HOURS, + "max_hours": SOURCE_BAN_MAX_HOURS, + "max_fails": MAX_CONSECUTIVE_FAILS, + }, + ) + .mappings() + .fetchone() + ) + db.commit() + if row is not None: + logger.warning( + "proxy_pool: proxy id=%d BANNED by source=%s — узел снят с выдачи ТОЛЬКО для " + "этого источника до %s (ban_count=%s); для остальных источников остаётся в " + "строю (#2600 п.2)", + proxy_id, + source, + row["banned_until"], + row["ban_count"], + ) + return + + # 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем + # текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет). + current = ( + db.execute( + text( + "SELECT enabled, disabled_reason FROM scrape_proxies " + "WHERE id = CAST(:id AS bigint)" + ), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + if current is None: + logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id) + else: + logger.warning( + "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " + "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " + "источнику (голодание хуже, чем работа через забаненный узел).", + proxy_id, + source, + ) + + +def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int: + """Снять баны узла по источникам (#2600 п.2). Returns число снятых строк. + + ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через + `PATCH /proxies/{id} enabled=true` — включение обнуляло `disabled_reason`, и узел + возвращался в строй. Теперь бан живёт в отдельной таблице и сам по себе истекает + только по таймеру, вплоть до 72 часов при эскалации. Без этой функции ложное + срабатывание детектора капчи (#2642) парковало бы узел на часы, а снять это можно + было бы только руками в SQL. + + ГДЕ ВЫЗЫВАЕТСЯ: + - `admin.patch_proxy` при ручном включении узла — «оператор включил» означает + чистый лист, ровно как обнуление disabled_reason рядом (#2610); + - после УСПЕШНОЙ ротации exit-IP (`proxy_rotation.rotate_proxy`) — площадка + банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса, + держа узел вне выдачи уже без причины. + + source=None — снять все баны узла; конкретный source — только его. DELETE, а не + `banned_until = now()`: строка живёт ещё и ради `ban_count` (память об эскалации), + а здесь мы как раз объявляем историю недействительной — новый бан начнётся с базовых + SOURCE_BAN_BASE_HOURS. + + `reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»). + """ + rows = db.execute( + text( + """ + DELETE FROM scrape_proxy_source_bans + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text)) + RETURNING source + """ + ), + {"proxy_id": proxy_id, "source": source}, + ).fetchall() + db.commit() + if rows: + logger.info( + "proxy_pool: cleared %d source ban(s) for proxy id=%d (%s) — reason=%s", + len(rows), + proxy_id, + [r.source for r in rows], + reason, + ) + return len(rows) def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: @@ -237,10 +736,17 @@ def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES return len(rows) -async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]: +async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | None]: """GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S). - Returns (ok, exit_ip, latency_ms). ok=False + (None, None) при любой ошибке. + Returns (ok, exit_ip, latency_ms, fail_kind). При успехе fail_kind=None. При неуспехе + exit_ip/latency_ms=None, а fail_kind классифицирует что случилось (#2600 п.2 — + транзиентный сбой узла ≠ перманентный бан, используется пока только для логов): + - "timeout" — сеть недоступна/медленная (httpx.TimeoutException) + - "connect_error" — прокси не поднят/не слушает/DNS (httpx.ConnectError) + - "http_error" — ipify ответил ошибкой через прокси (auth/upstream) + - "other" — прочее + url несёт схему (http:// / socks5://) — httpx[socks] обрабатывает оба. """ started = time.monotonic() @@ -250,10 +756,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]: resp.raise_for_status() ip = resp.json().get("ip") latency_ms = int((time.monotonic() - started) * 1000) - return True, (str(ip) if ip else None), latency_ms + return True, (str(ip) if ip else None), latency_ms, None + except httpx.TimeoutException: + logger.warning("proxy_pool: health probe timeout proxy=%s", _mask(url)) + return False, None, None, "timeout" + except httpx.ConnectError: + logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url)) + return False, None, None, "connect_error" + except httpx.HTTPStatusError as exc: + logger.warning( + "proxy_pool: health probe http_error proxy=%s status=%s", + _mask(url), + exc.response.status_code, + ) + return False, None, None, "http_error" except Exception: logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True) - return False, None, None + return False, None, None, "other" def _mask(url: str) -> str: @@ -269,16 +788,29 @@ def _mask(url: str) -> str: async def run_proxy_healthcheck(db: Session) -> dict[str, int]: - """Периодический health-check всех enabled-прокси пула (#2162). + """Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600). - Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого - enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через - mark_health (успех → сброс fails + свежий exit_ip/latency; фейл → инкремент, - авто-disable при DISABLE_THRESHOLD). + Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу + через каждый кандидат и пишет результат через mark_health (успех → сброс fails + + enabled=true + свежий exit_ip/latency; фейл → инкремент, авто-disable при + DISABLE_THRESHOLD). + + Кандидаты: ВСЕ enabled-узлы (как раньше) + disabled-узлы, которые ни разу не + проверялись (last_check_at IS NULL) или проверялись давнее DISABLED_RECHECK_MINUTES + назад. Без этого auto-disable необратим — узел, ушедший в disable из-за транзиентного + сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба + disabled-узла реанимирует его (enabled=true через mark_health) — инкрементит `revived` + и пишет отдельный INFO-лог. Ручно-выключенные узлы (disabled_reason НЕ NULL, #2610) + тоже пробуются (чтобы после ручного включения признак немедленно ожил без ожидания + следующего disable/enable цикла), но mark_health их не воскрешает — revived не растёт, + WARNING пишет сам mark_health. + + В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад + (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters - {reaped, checked, ok, failed}. + {reaped, checked, ok, failed, revived, bans_purged}. """ reaped = reap_stale_leases(db) @@ -286,12 +818,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind + SELECT id, url, kind, enabled, disabled_reason FROM scrape_proxies WHERE enabled + OR last_check_at IS NULL + OR last_check_at < now() - make_interval( + mins => CAST(:disabled_recheck_minutes AS integer) + ) ORDER BY id """ - ) + ), + {"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES}, ) .mappings() .all() @@ -300,22 +837,64 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: checked = 0 ok_count = 0 failed = 0 + revived = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) - ok, exit_ip, latency_ms = await _probe_proxy(url) - mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms) + was_disabled = not bool(row["enabled"]) + manually_disabled = row["disabled_reason"] is not None + ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url) + mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind) checked += 1 if ok: ok_count += 1 + # manually_disabled → mark_health не тронул enabled (см. её WARNING-лог); + # revived считает только реальное авто-воскрешение (#2610). + if was_disabled and not manually_disabled: + revived += 1 + logger.info( + "proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, " + "returned to service (enabled=true, consecutive_fails=0)", + proxy_id, + ) else: failed += 1 + # Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS, + # НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует + # (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации. + # Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с + # 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса: + # неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать». + purged = len( + db.execute( + text( + """ + DELETE FROM scrape_proxy_source_bans + WHERE banned_until < now() - make_interval(days => CAST(:days AS integer)) + RETURNING proxy_id + """ + ), + {"days": SOURCE_BAN_PURGE_DAYS}, + ).fetchall() + ) + db.commit() + logger.info( - "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d", + "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " + "bans_purged=%d", reaped, checked, ok_count, failed, + revived, + purged, ) - return {"reaped": reaped, "checked": checked, "ok": ok_count, "failed": failed} + return { + "reaped": reaped, + "checked": checked, + "ok": ok_count, + "failed": failed, + "revived": revived, + "bans_purged": purged, + } diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py new file mode 100644 index 00000000..c17641d8 --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -0,0 +1,376 @@ +"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5). + +АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный +PR #2609, конфликт исключён: вся новая логика тут, в новом модуле). + +Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой): + - Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=) + для безлимитных портов НЕ работает. + - Ротация сменой session-суффикса логина (-session-N) НЕ работает — exit-IP + не меняется (три варианта дали один и тот же IP). + - Единственный рабочий путь — ручка веб-кабинета: + POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip + Authorization: Bearer <токен> + Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}. + scrape_proxies.rotate_url уже несёт этот URL (миграция 199) — токен НЕ в URL, + он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token). + - Лимит провайдера: 3 ротации в сутки на порт. + - Токен — сессионный, однажды протухнет (осознанное решение владельца аккаунта). + Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже + (logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка. + +Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198): + Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера + и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование: + 401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на + уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не + мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые + ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не + считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные + отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не + доходят вовсе — в таблицу не пишутся и лимит не трогают. + + quota-consuming := http_status IS NOT NULL AND http_status != 401 + (успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx — + если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до + реальной rotate-логики и мог быть учтён в лимите на его стороне). + +⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте +исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через +str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь +admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали, +поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True. + +⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка +НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip- +ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`, +тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip / +Settings.avito_proxy_rotate_url), не ASocks. +Без явной проверки хоста наш `Authorization: Bearer ` ушёл бы +на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно, +реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как +успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет +urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение +это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без +Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый +вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks +ротировал", а фактически задел mobileproxy), которого проще не допустить, чем +потом объяснять админу расхождение счётчиков. + +psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +import httpx +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.services.proxy_pool import clear_source_bans + +logger = logging.getLogger(__name__) + +__all__ = [ + "ALLOWED_ROTATE_HOST", + "DAILY_ROTATION_LIMIT", + "RotationResult", + "rotate_proxy", +] + +# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика). +DAILY_ROTATION_LIMIT = 3 + +# Таймаут POST refresh-ip. Пункт задачи требует "~30с". +_ROTATE_TIMEOUT_S = 30.0 + +# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке +# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может +# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова. +ALLOWED_ROTATE_HOST = "api.asocks.com" + + +def _is_allowed_rotate_url(url: str) -> bool: + """https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо — + urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url.""" + try: + parsed = urlparse(url) + except ValueError: + return False + return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST + + +@dataclass +class RotationResult: + """Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный + (безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты.""" + + ok: bool + reason: str | None + new_ip: str | None = None + # Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль + # docstring за определением quota-consuming). Для локально отклонённых попыток + # (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток. + rotations_remaining_today: int = DAILY_ROTATION_LIMIT + + +def _quota_used_today(db: Session, proxy_id: int) -> int: + """Число quota-consuming попыток за последние 24ч (см. docstring модуля). + + http_status IS NOT NULL AND != 401 — успех И любой не-401 ответ провайдера. + 401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются. + """ + row = ( + db.execute( + text( + """ + SELECT count(*) AS n + FROM scrape_proxy_rotations + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND rotated_at > now() - interval '24 hours' + AND http_status IS NOT NULL + AND http_status != 401 + """ + ), + {"proxy_id": proxy_id}, + ) + .mappings() + .fetchone() + ) + return int(row["n"]) if row is not None else 0 + + +def _record_attempt( + db: Session, + proxy_id: int, + *, + success: bool, + http_status: int | None, + note: str | None, +) -> None: + """Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос + к провайдеру реально был сделан (локально отклонённые попытки не пишутся — + см. модуль docstring).""" + db.execute( + text( + """ + INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note) + VALUES ( + CAST(:proxy_id AS bigint), + CAST(:success AS boolean), + CAST(:http_status AS integer), + CAST(:note AS text) + ) + """ + ), + {"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note}, + ) + db.commit() + + +def _alert_stale_token(proxy_id: int) -> None: + """Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort). + + 401 значит, что провайдер отверг Authorization-заголовок — токен протух (issue + #2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение + владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать + об этом сразу, а не когда прокси уже забанены неделю. + """ + logger.error( + "proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d — " + "ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy " + "until the token is refreshed in web-cabinet + env", + proxy_id, + ) + try: + import sentry_sdk + + sentry_sdk.capture_message( + f"ASocks rotation token rejected (401) for proxy_id={proxy_id} — " + "ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed", + level="error", + ) + except Exception: + pass # sentry_sdk not initialised in dev — best-effort only + + +def _extract_new_ip(resp: httpx.Response) -> str | None: + """Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа + refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) — + парсинг заведомо defensive, неудача не является ошибкой ротации.""" + try: + data: Any = resp.json() + except Exception: + return None + if not isinstance(data, dict): + return None + for key in ("new_ip", "ip", "exit_ip"): + val = data.get(key) + if val: + return str(val) + nested = data.get("data") + if isinstance(nested, dict): + for key in ("new_ip", "ip", "exit_ip"): + val = nested.get(key) + if val: + return str(val) + return None + + +async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: + """Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5). + + Порядок: + 1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный. + 2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка). + 3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False + ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy + changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле). + 4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, + внятный отказ, ничего не ломается. + 5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ + обращения к API. + 6. POST rotate_url с Authorization: Bearer , timeout ~30с. + - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL + (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. + - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается + в лимите), нейтральный reason. + - Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл + auth и ответил своей бизнес-логикой), нейтральный reason. + - 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort. + + Ни в одном из reason/логов НЕ появляется токен. + """ + row = ( + db.execute( + text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + if row is None: + return RotationResult(ok=False, reason="proxy not found") + + rotate_url = row["rotate_url"] + if not rotate_url: + logger.info( + "proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (no rotate_url configured)" + ) + + if not _is_allowed_rotate_url(rotate_url): + # scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут + # mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш + # Authorization: Bearer на непроверенный хост нельзя. + # Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он + # несёт их собственный секрет в query-string, тот же класс утечки, что + # и в rotate_proxy_ip, см. модуль docstring). + logger.warning( + "proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host " + "(%s) — refusing before any HTTP call to avoid leaking the token to it", + proxy_id, + urlparse(rotate_url).hostname, + ALLOWED_ROTATE_HOST, + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (unexpected rotate host)" + ) + + token = settings.asocks_api_token + if not token: + logger.warning( + "proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped", + proxy_id, + ) + return RotationResult(ok=False, reason="rotation not configured (missing API token)") + + used = _quota_used_today(db, proxy_id) + if used >= DAILY_ROTATION_LIMIT: + logger.warning( + "proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call", + proxy_id, + used, + DAILY_ROTATION_LIMIT, + ) + return RotationResult( + ok=False, + reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)", + rotations_remaining_today=0, + ) + + try: + async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: + resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"}) + except Exception as exc: + # Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера, + # значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести + # служебные детали соединения) — только exc_info=True в лог. type(exc).__name__ + # секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note + # ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли". + logger.warning( + "proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True + ) + _record_attempt( + db, + proxy_id, + success=False, + http_status=None, + note=f"request failed: {type(exc).__name__}", + ) + return RotationResult( + ok=False, + reason="rotation request failed (network error)", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used), + ) + + status = resp.status_code + + if status == 401: + _alert_stale_token(proxy_id) + _record_attempt( + db, + proxy_id, + success=False, + http_status=401, + note="unauthenticated — token expired/invalid (excluded from daily quota)", + ) + return RotationResult( + ok=False, + reason="rotation service rejected credentials — alerted, contact operator", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used), + ) + + if status >= 400: + logger.warning( + "proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status + ) + _record_attempt( + db, proxy_id, success=False, http_status=status, note="provider returned error" + ) + return RotationResult( + ok=False, + reason=f"rotation request failed (provider status {status})", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), + ) + + new_ip = _extract_new_ip(resp) + logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip) + _record_attempt(db, proxy_id, success=True, http_status=status, note=None) + # Площадки банили СТАРЫЙ exit-IP, а строка бана привязана к proxy_id (#2600 п.2) — + # после смены адреса она держала бы узел вне выдачи уже без причины, вплоть до 72ч + # при эскалации. Ротация прошла → история банов этого узла недействительна. + clear_source_bans(db, proxy_id, reason=f"exit ip rotated (status={status})") + return RotationResult( + ok=True, + reason=None, + new_ip=new_ip, + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), + ) diff --git a/tradein-mvp/backend/app/services/rosreestr_poll.py b/tradein-mvp/backend/app/services/rosreestr_poll.py index 1ef1d8df..3d278acd 100644 --- a/tradein-mvp/backend/app/services/rosreestr_poll.py +++ b/tradein-mvp/backend/app/services/rosreestr_poll.py @@ -50,9 +50,23 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн, что DEFAULT_UA в zhkh_flats_loader.py). -При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем -available=False. Отсутствие папки/файла квартала → available=False (штатный -случай до публикации квартала, до начала следующего месяца после конца квартала). +УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только +запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)): + - Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная + опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча + врал целыми кварталами. Такое обязано быть событием. + - Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога → + ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html). + Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали), + но цена асимметрична — ложное срабатывание стоит одного события в месяц (такт + 28 дней), пропуск стоит квартала молчания. + - Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц + (такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный + deals_freshness_monitor ERROR-ом по max(deal_date). + - Папки/файла квартала нет → INFO. Штатное состояние до публикации: квартал выходит + 4 раза в год, поллер ходит 12 — большинство прогонов ЗАКОННО пустые. + - Квартал вышел → INFO + ЯВНОЕ событие capture_message(level="info"), см. + poll_rosreestr_new_quarter. """ from __future__ import annotations @@ -63,6 +77,7 @@ from typing import Any from urllib.parse import quote, unquote, urljoin import httpx +import sentry_sdk from sqlalchemy import text from sqlalchemy.orm import Session @@ -243,7 +258,8 @@ async def check_new_quarter_available( try: index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True) if index_resp.status_code != 200: - logger.warning( + # ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ". + logger.error( "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable", index_resp.status_code, _DATA_SETS_BASE_URL, @@ -265,7 +281,9 @@ async def check_new_quarter_available( folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href) folder_resp = await client.get(folder_url, follow_redirects=True) if folder_resp.status_code != 200: - logger.warning( + # ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается — + # это уже не «ещё не опубликовали», а поломка портала. + logger.error( "rosreestr_poll: unexpected HTTP %d listing folder %s — " "treating Q%d %d as unavailable", folder_resp.status_code, @@ -309,7 +327,13 @@ async def check_new_quarter_available( ) return True - logger.info( + # ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip + # либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже + # врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ"). + # Ветка может сработать и легитимно — файл появился в листинге раньше, чем + # докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание + # стоит максимум одного события в месяц, а пропуск стоит квартала молчания. + logger.error( "rosreestr_poll: Q%d %d file found (%s) but failed availability check " "(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, " "treating as unavailable", @@ -338,12 +362,14 @@ async def check_new_quarter_available( exc, ) return False - except Exception as exc: - logger.warning( - "rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable", + except Exception: + # ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал + # парсер href'ов), а не сбой сети. Под WARNING он молча превращался в + # «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами. + logger.exception( + "rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable", quarter, year, - exc, ) return False @@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]: rosreestr_dataset_url(next_year, next_quarter), _DATA_SETS_BASE_URL, ) + # #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт + # много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и + # теряется на редеплое. Отсюда явный capture_message вместо logger.error: + # событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой». + # Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в + # год, а повтор до самого импорта — это и есть нужное напоминание (#2670). + try: + sentry_sdk.capture_message( + f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year} — " + "нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)", + level="info", + ) + except Exception: + # Алертинг best-effort: падение отправки события не должно валить поллер. + logger.warning("rosreestr_poll: capture_message failed", exc_info=True) return { "available": available, diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 36b7bc95..7a4d646c 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -464,8 +464,16 @@ async def pull_sber_indices( # path or its filter dims are stale (sber renames slugs / changes # dimension codes). Surface it loudly with the slug + filter so the # next breakage is diagnosable instead of a silent error-counter bump. + # + # #2674: "loudly" было сказано, но написано WARNING — тише, чем + # соседние 5xx/сетевые ветки, и НЕ событие в скрапере + # (LoggingIntegration event_level=ERROR). При этом 404 — самая + # ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а + # переименованный slug будет 404-ить каждый месяц, пока человек не + # перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк + # перестаёт обновляться. if exc.response.status_code == 404: - logger.warning( + logger.error( "sber_index: 404 for dashboard=%s ref_area=%s filter=%s — " "dataset-path invalid? slug renamed or filter dims stale " "(re-capture /dataset/v1/ via dashboard route-interception)", diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 114ac577..b1a43824 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне удалённой machinery: - `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run"). + С #2674 это re-export kit-версии, а не вторая копия формулы. - `has_running_run` — читается admin.py (UI-индикатор "уже бежит"). - `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом product_handlers._job_rosreestr_dkp (lazy import). @@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в from __future__ import annotations import logging -import random -from datetime import UTC, datetime, time, timedelta from typing import Any +# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674). +# Обе копии одинаково умели interval_days — но такт доезжал до next_run_at только через +# kit (_claim_run/_defer_next_run_at читают default_params["interval_days"]); admin.py +# звал эту копию БЕЗ аргумента, получал default=1 и сбивал любой источник на «завтра». +# Копия удалена, а не подправлена: пока формула лежит в двух файлах, следующая правка +# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих) +# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах. +from scraper_kit.orchestration.scheduler import compute_next_run_at from sqlalchemy import text from sqlalchemy.orm import Session from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod +__all__ = ["compute_next_run_at", "has_running_run"] + logger = logging.getLogger(__name__) # import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше @@ -43,54 +52,6 @@ logger = logging.getLogger(__name__) DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05 -def compute_next_run_at( - window_start_hour: int, - window_end_hour: int, - *, - now: datetime | None = None, - interval_days: int = 1, -) -> datetime: - """Pick random datetime в window [start, end) UTC, через interval_days суток после now. - - interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly. - Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа - → 1 → прежнее ежедневное поведение. - - Если window_end_hour <= window_start_hour → cross-midnight window - (например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59). - """ - now = now or datetime.now(tz=UTC) - interval_days = max(1, int(interval_days)) - # Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше). - target = (now + timedelta(days=interval_days)).date() - - if window_end_hour > window_start_hour: - # Обычное окно (например 2..5 → 02:00-04:59) - start_seconds = window_start_hour * 3600 - end_seconds = window_end_hour * 3600 - rand_seconds = random.randint(start_seconds, end_seconds - 1) - return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds) - else: - # Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59) - # Длина окна = (24-start) + end часов - total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600 - rand_seconds = random.randint(0, total_seconds - 1) - # Если rand попадает в первую часть (start..24) - first_half = (24 - window_start_hour) * 3600 - if rand_seconds < first_half: - # interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day. - # interval_days>1: всегда целевая дата (стаггер на N суток вперёд). - today_ok = interval_days == 1 and now.hour < window_start_hour - base_date = now.date() if today_ok else target - return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta( - seconds=window_start_hour * 3600 + rand_seconds - ) - else: - # Во второй части (0..end), целевого дня - offset = rand_seconds - first_half - return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset) - - def has_running_run(db: Session, source: str) -> bool: """Есть ли активный run для source (status='running').""" row = db.execute( @@ -115,7 +76,16 @@ async def _execute_cian_backfill( """Orchestrate Cian history backfill with heartbeat + checkpoint. Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat) - before and after the batch call for zombie-detection visibility. + НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал + живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно + heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод- + прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том + что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до + 5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346). + Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что + после ложной пометки собственный финал прогона становится no-op (отсюда нулевые + counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик + может запустить второй такой же батч поверх работающего. Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS NULL via LEFT JOIN — so re-running after a partial completion naturally skips @@ -124,9 +94,33 @@ async def _execute_cian_backfill( Params (from default_params jsonb): batch_size: int — rows per run (listings + houses counted separately). """ - from app.tasks.cian_history_backfill import backfill_cian_history + from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history batch_size = int(params.get("batch_size", 100)) + + def _counters(result: CianBackfillResult) -> dict[str, int]: + return { + "listings_processed": result.listings_processed, + "listings_succeeded": result.listings_succeeded, + "listings_failed": result.listings_failed_fetch + result.listings_failed_save, + "houses_processed": result.houses_processed, + "houses_succeeded": result.houses_succeeded, + "houses_failed": result.houses_failed_fetch + result.houses_failed_save, + } + + def _heartbeat(progress: CianBackfillResult) -> None: + """Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен + ронять уже идущую работу — прогон в худшем случае вернётся к прежнему + поведению (пометка 'zombie' на 6-м часу).""" + try: + runs_mod.update_heartbeat(db, run_id, _counters(progress)) + except Exception: + logger.warning( + "scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)", + run_id, + exc_info=True, + ) + counters: dict[str, int] = { "listings_processed": 0, "listings_succeeded": 0, @@ -145,17 +139,10 @@ async def _execute_cian_backfill( do_listings=True, do_houses=True, do_valuations=False, + on_progress=_heartbeat, ) - counters = { - "listings_processed": result.listings_processed, - "listings_succeeded": result.listings_succeeded, - "listings_failed": result.listings_failed_fetch + result.listings_failed_save, - "houses_processed": result.houses_processed, - "houses_succeeded": result.houses_succeeded, - "houses_failed": result.houses_failed_fetch + result.houses_failed_save, - "duration_sec": int(result.duration_sec), - } + counters = {**_counters(result), "duration_sec": int(result.duration_sec)} runs_mod.mark_done(db, run_id, counters) logger.info( "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index d38ddcc0..789be992 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -2,12 +2,39 @@ Таблица scrape_runs создана в 015_scrape_runs.sql. Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled. + +ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL — +синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции и не двигается, +сколько бы та ни жила. Финализаторы (mark_done/mark_failed/mark_banned) выполняются +ТОЙ ЖЕ сессией, что и работа задачи, — и если рабочая транзакция всё это время +оставалась открытой (задача ничего не коммитила: нечего было сохранять, батч читающий, +сохранение шло чужой сессией), их UPDATE попадал ВНУТРЬ неё, и `finished_at` получал +время НАЧАЛА работы, а не её конца. + +Замер на проде 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик +counters.duration_sec): у 153 заявленная длительность превышала собственное окно +finished_at − started_at более чем в 1.5 раза, у 133 окно было меньше секунды при +работе дольше 10 с. 126 из этих 133 окон лежат в диапазоне 9-64 мс — это не разброс, +а подпись механизма: столько проходит от коммита claim'а до первого запроса рабочей +транзакции. Крайний случай — прогон 346 (cian_history_backfill): 18230 с работы, +окно 32 мс. + +Дефект был не сплошной ровно потому, что зависел от того, коммитила ли задача перед +финалом: cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят +поштучно, у них окно совпадало с работой; yandex_address_backfill (45 из 50 прогонов), +newbuilding_enrich, cian_history_backfill — нет. + +Побочно это чинит и `heartbeat_at`: он писался тем же `now()` и по той же причине +отставал от реальности на возраст открытой транзакции, а на нём стоит поиск зависших +прогонов (reap_zombies, порог 6 ч). """ from __future__ import annotations import json import logging +from collections.abc import Callable, Mapping +from functools import cache from typing import Any import sentry_sdk @@ -21,6 +48,130 @@ logger = logging.getLogger(__name__) # (anti-spam: не на каждой последующей). CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3 +# #2625: количество последовательных 'done' запусков с нулевым бизнес-результатом +# (total_seen=0), при достижении которого отправляется Sentry alert. Статус 'done' +# формально успешен (errors_count=0), но капча/пустая выдача/смена вёрстки источника +# без детекта (см. providers/cian, providers/yandex) деградируют молча — этот класс +# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned). +CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3 + +# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается +# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й +# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника +# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд, +# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на +# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от +# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма. +# +# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не +# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не +# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз +# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из +# них всегда будет либо спамом, либо молчанием. +STREAK_ALERT_MAX_PERIOD = 16 + +# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок +# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда» +# невозможно по построению, а не по счастливому совпадению чисел. +STREAK_SCAN_LIMIT = 500 + + +def _streak_alert_due(streak: int, threshold: int) -> bool: + """Достиг ли стрик очередной вехи напоминания (#2670). + + True на threshold, 2×, 4×, 8×… и дальше на каждом кратном + STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше — + на N-й подряд неудаче; меняется только то, что он не последний. + """ + if streak < threshold or streak % threshold: + return False + mult = streak // threshold + if mult % STREAK_ALERT_MAX_PERIOD == 0: + return True + return mult & (mult - 1) == 0 + + +def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: + """Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми).""" + streak = 0 + for row in rows: + if not is_bad(row): + break + streak += 1 + return streak + + +# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218) +# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый +# статус» и «явное поле причины»: +# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed +# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив +# статус, пришлось бы дублировать её в каждом потребителе. +# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых +# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы, +# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results), +# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот +# класс оборванной проводки, из-за которого задача и появилась. +# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить +# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние. +BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее +BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее + + +def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: + """Первое присутствующее из ``keys`` как int; None — ни одного ключа нет.""" + for key in keys: + val = counters.get(key) + if val is not None: + try: + return int(val) + except (TypeError, ValueError): + return None + return None + + +# #2703: ключи, которыми задача сообщает СВОЙ бизнес-результат. Список намеренно +# короткий и состоит из синонимов ОДНОЙ величины — «сколько объявлений отдала выдача»: +# total_seen — если задача посчитала сама; +# lots_fetched — все city/newbuilding-sweep'ы (21 источник, 455 прогонов на проде); +# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше +# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов +# реально дали ноль. +# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 +# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь +# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched +# …), а у refresh_search_matview counters пусты буквально ({} во всех 55 строках) и у +# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ +# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их +# словарь, а честно признаёт, что мерить нечем — см. _run_result_count. +_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched") + + +def _run_result_count(counters: Mapping[str, Any] | None) -> int | None: + """Бизнес-результат прогона; **None = прогон его не сообщил** (≠ ноль). + + Ровно это различие и было потеряно: сторож читал колонку ``total_seen``, у + которой DEFAULT 0, поэтому «не измерено» и «измерено, ноль» выглядели одинаково. + """ + return _pick_int(counters or {}, *_RESULT_COUNTER_KEYS) + + +@cache +def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> None: + """Один раз на процесс: у источника нет ключа, по которому сторож судит (#2703). + + Не алерт — алертить не о чем, судить не о чем тоже. Это делает слепую зону + ВИДИМОЙ: раньше её признаком был вечно молчащий сторож, выглядящий настроенным. + """ + logger.warning( + "zero-result watchdog неприменим к source=%s: counters не содержат ни одного " + "результатного ключа %s (есть: %s) — прогоны этого источника больше не считаются " + "нулевыми по умолчанию (#2703)", + source, + _RESULT_COUNTER_KEYS, + ", ".join(keys) or "<пусто>", + ) + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -32,41 +183,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). Приоритет ключей: - - total_seen ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched' - - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' + - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched) + - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). """ - - def _pick(*keys: str) -> int | None: - for key in keys: - val = counters.get(key) - if val is not None: - try: - return int(val) - except (TypeError, ValueError): - return None - return None - - return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted") + return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted") def _alert_if_consecutive_failures(db: Session, source: str) -> None: - """Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD - завершённых запусков для данного source имеют статус 'failed' или 'banned'. + """Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд + (статусы 'failed'/'banned') у данного source. - Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос - возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является - failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога. + Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см. + _streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не + повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде + это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного + прогона, один алерт за всё время. + + Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это + достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47 + завершённых прогонах), поэтому лестница не вырождается в постоянный алерт. Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный Sentry НЕ должен нарушать вызывающий mark_* путь. """ + if sentry_sdk is None: + return n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD try: - # Берём последние N+1 завершённых (non-running) запусков по source. - # Сортируем по finished_at DESC чтобы самые свежие шли первыми. + # Завершённые (non-running) прогоны источника, самые свежие первыми. rows = db.execute( text( """ @@ -77,39 +224,116 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None: LIMIT :limit """ ), - {"source": source, "limit": n + 1}, + {"source": source, "limit": STREAK_SCAN_LIMIT}, ).fetchall() - if len(rows) < n: - # Ещё не набралось N завершённых запусков вообще — алерт не нужен. + streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned")) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): return - # Первые N должны быть все failed/banned. - first_n = rows[:n] - if not all(r.status in ("failed", "banned") for r in first_n): - return - - # (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже - # должны были отправить алерт раньше и не стоит дублировать. - if len(rows) > n and rows[n].status in ("failed", "banned"): - return - - # Стрик ровно достиг порога — отправляем алерт. sentry_sdk.capture_message( - f"Scraper source '{source}' has {n} consecutive failed/banned runs — " + f"Scraper source '{source}' has {streak} consecutive failed/banned runs — " "manual intervention may be required (expired cookies / ban / broken parser).", level="error", ) logger.error( - "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n + "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak ) except Exception: pass # sentry_sdk not initialised in dev, or query failed — best-effort only -def _alert_on_run_id(db: Session, run_id: int) -> None: - """Вспомогательная обёртка: извлекает source по run_id и вызывает - _alert_if_consecutive_failures. Best-effort — не бросает исключений. +def _alert_if_consecutive_zero_results(db: Session, source: str) -> None: + """Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD + завершённых 'done' запусков для source дали ИЗМЕРЕННЫЙ нулевой результат (#2625). + + Отличается от _alert_if_consecutive_failures: статус здесь формально 'done' + (errors_count=0) — деградация невидима существующему failed/banned алерту. + Причина обычно капча/пустая выдача источника, у которого нет (или не сработал) + детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py). + + Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures + (#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина». + + #2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может + прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53 + источников не заполняет ничто — значит у них он читал 0 ВСЕГДА, в том числе у + полностью успешного прогона, стрик не прерывался никогда, и после первого + события сторож замолкал навсегда, продолжая выглядеть настроенным. Теперь + признак берётся из counters, а «не измерено» (None) стрик ПРЕРЫВАЕТ — ложный + вечный стрик стал невозможен по построению, а слепая зона логируется явно. + + Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный + Sentry НЕ должен нарушать вызывающий mark_done путь. + """ + n = CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD + try: + # Те же non-running статусы, что у _alert_if_consecutive_failures — стрик + # 'done'-с-нулём прерывается ЛЮБЫМ другим завершением (failed/banned/done- + # с-результатом/cancelled/прогон без результатной метрики), не только успешным + # сбором. counters, а НЕ колонка total_seen: у колонки DEFAULT 0, по ней + # «не измерено» неотличимо от «ноль» (#2703). + rows = db.execute( + text( + """ + SELECT status, counters FROM scrape_runs + WHERE source = :source + AND status IN ('failed', 'banned', 'done', 'cancelled') + ORDER BY finished_at DESC NULLS LAST + LIMIT :limit + """ + ), + {"source": source, "limit": STREAK_SCAN_LIMIT}, + ).fetchall() + + if not rows: + return + + def _is_zero_done(r: Any) -> bool: + """Только ИЗМЕРЕННЫЙ ноль. Прогон без результатной метрики стрик ПРЕРЫВАЕТ. + + Так недостижимое условие прерывания невозможно по построению: источник, + чей словарь счётчиков сторожу неизвестен, не копит ложный стрик и не + запирает анти-спам «один раз на стрик» в «один раз навсегда». + """ + return r.status == "done" and _run_result_count(r.counters) == 0 + + if _run_result_count(rows[0].counters) is None: + # Свежайший завершённый прогон не сообщил результата — судить нечем. + # Логируем (один раз на источник за процесс) вместо молчаливого нуля. + _warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {}))) + return + + streak = _leading_streak(rows, _is_zero_done) + capped = streak >= STREAK_SCAN_LIMIT + if not capped and not _streak_alert_due(streak, n): + return + + sentry_sdk.capture_message( + f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero " + "lots fetched — captcha/layout-change likely undetected " + "(manual check recommended).", + level="error", + ) + logger.error( + "sentry alert sent: source=%s has %d consecutive zero-result 'done' runs", + source, + streak, + ) + except Exception: + pass # sentry_sdk not initialised in dev, or query failed — best-effort only + + +def _alert_on_run_id( + db: Session, + run_id: int, + *, + checker: Callable[[Session, str], None] = _alert_if_consecutive_failures, +) -> None: + """Вспомогательная обёртка: извлекает source по run_id и вызывает `checker` + (default _alert_if_consecutive_failures; mark_done передаёт + _alert_if_consecutive_zero_results — #2625). Best-effort — не бросает исключений. """ try: row = db.execute( @@ -118,22 +342,29 @@ def _alert_on_run_id(db: Session, run_id: int) -> None: ).fetchone() if row is None: return - _alert_if_consecutive_failures(db, str(row.source)) + checker(db, str(row.source)) except Exception: pass def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: - """INSERT scrape_runs(source, status='running', params, started_at=NOW()). + """INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()). - run_type DEFAULT 'city_sweep' (из 051 миграции). + started_at пишется СВОЕЙ транзакцией (db.commit() ниже) — откат рабочей + транзакции задачи его уже не достаёт (#2702). + + Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); + отдельной колонки run_type больше нет — она 3244 прогона подряд молчала + дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674). Returns run_id (bigint). """ row = db.execute( text( """ INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at) - VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW()) + VALUES ( + :source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp() + ) RETURNING id """ ), @@ -145,7 +376,7 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: - """UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки. + """UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). @@ -156,7 +387,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None text( """ UPDATE scrape_runs - SET heartbeat_at = NOW(), + SET heartbeat_at = clock_timestamp(), counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -173,6 +404,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None db.commit() +# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле. +# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы +# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а +# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том +# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27). +# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех +# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import +# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не +# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста +# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась. +_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"}) +_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load") + + +def honors_cancel(source: str) -> bool: + """True, если джоб этого source опрашивает отмену и реально остановится.""" + return source in _CANCEL_HONORING_EXACT or any( + key in source for key in _CANCEL_HONORING_SUBSTRINGS + ) + + def is_cancelled(db: Session, run_id: int) -> bool: """Проверить status='cancelled' (cooperative cancel в long-running pipeline).""" row = db.execute( @@ -183,7 +435,7 @@ def is_cancelled(db: Session, run_id: int) -> bool: def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: - """Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count. + """Финализация run: status='done', finished_at + counters + total_seen/new_count. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). @@ -193,7 +445,8 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: text( """ UPDATE scrape_runs - SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'done', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -211,6 +464,10 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: if row is None: logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id) db.commit() + # #2625: N подряд 'done' с нулевым бизнес-результатом — деградация, невидимая + # для failed/banned алерта (капча/пустая выдача под видом успеха). Best-effort, + # после коммита — статус уже персистирован в БД. + _alert_on_run_id(db, run_id, checker=_alert_if_consecutive_zero_results) def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None: @@ -228,7 +485,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) text( """ UPDATE scrape_runs - SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'failed', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), error = :error, counters = CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) @@ -251,11 +509,29 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) _alert_on_run_id(db, run_id) -def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None: - """Финализация run: status='banned' (IP заблокирован Avito — 403/captcha). +def mark_banned( + db: Session, + run_id: int, + error: str, + counters: dict[str, int], + *, + ban_kind: str = BAN_KIND_PLATFORM, +) -> None: + """Финализация run: status='banned' + диагноз ban_kind (#2686). Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'. - Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа. + Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим + багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его + теряет). Cooldown 2-4 часа. + + `ban_kind` разводит два исхода, которые раньше схлопывались в один статус: + - BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha); + - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси). + Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора + текста ошибки. Default 'platform' = историческая семантика статуса, поэтому + вызывающие, которым разводить нечего, не меняются. + + Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом. Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE, он мог оставить сессию в error state — rollback сбрасывает состояние. @@ -269,8 +545,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) text( """ UPDATE scrape_runs - SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(), + SET status = 'banned', + finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), error = :error, counters = CAST(:counters AS jsonb), + ban_kind = :ban_kind, total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) WHERE id = :run_id AND status = 'running' @@ -281,6 +559,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) "run_id": run_id, "error": error[:1000], "counters": json.dumps(counters), + "ban_kind": ban_kind, "total_seen": total_seen, "new_count": new_count, }, @@ -292,13 +571,93 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) _alert_on_run_id(db, run_id) +def mark_backfill_finished( + db: Session, + run_id: int, + counters: dict[str, int], + *, + source: str, + aborted_by_blocks: bool = False, +) -> None: + """Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'. + + Все три detail-backfill'а (avito/yandex/domclick) финализировались ОДНИМ + mark_done: прогон, который сделал N попыток и не обогатил НИ ОДНОГО объявления, + отчитывался успехом. На проде (2026-08-06) это 78 прогонов из 158 — + avito 23/76 (в т.ч. 5 прогонов по 1500-1600 попыток с нулём обогащений), + yandex 31/52 (все attempted=5 failed=5), domclick 24/30 (494 попытки → 0). + + Существующие алерты этот класс не ловили: _alert_if_consecutive_failures + считает только failed/banned, а _alert_if_consecutive_zero_results смотрит + total_seen, которого в counters backfill'ов нет вовсе (всегда 0 → стрик не + прерывается никогда → анти-спам молчит после первого раза). + + Правила (порядок важен), по образцу #2657 для domclick_city_sweep: + - попыток не было (attempted=0) → 'done', честная пустота: кандидатов нет; + - есть блоки источника И (прогон оборван брейкером ИЛИ ноль результата) + → 'banned': external constraint, не наш баг (и триггер ротации IP #2611); + - ноль результата без блоков → 'failed': это наша поломка (парсер/сеть/БД); + - иначе (обогатили хоть что-то) → 'done', в т.ч. частичный прогон. + + `gone` (404 у avito) считается результатом наравне с `enriched`: прогон, + который подтвердил снятие объявлений, работу сделал. + """ + attempted = int(counters.get("attempted") or 0) + enriched = int(counters.get("enriched") or 0) + blocked = int(counters.get("blocked") or 0) + produced = enriched + int(counters.get("gone") or 0) + + if attempted == 0: + mark_done(db, run_id, counters) + return + + if blocked and (aborted_by_blocks or produced == 0): + reason = ( + f"backfill-honest-status: {source} остановлен блоками источника — " + f"blocked={blocked}, обогащено {enriched} из {attempted} попыток (#2674)" + ) + logger.error("%s run_id=%d", reason, run_id) + mark_banned(db, run_id, reason, counters) + return + + if produced == 0: + reason = ( + f"backfill-honest-status: {source} без результата — 0 обогащено из " + f"{attempted} попыток (failed={counters.get('failed', 0)}, " + f"blocked={blocked}) (#2674)" + ) + logger.error("%s run_id=%d", reason, run_id) + mark_failed(db, run_id, reason, counters) + return + + mark_done(db, run_id, counters) + + def mark_cancelled(db: Session, run_id: int) -> bool: - """Set status='cancelled' если currently 'running'. Returns True если cancelled.""" + """Set status='cancelled' если currently 'running'. Returns True если cancelled. + + Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel: + там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard. + Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому + гейт стоит здесь — на общем узле всех пяти. + """ + row = db.execute( + text("SELECT source FROM scrape_runs WHERE id = :run_id"), + {"run_id": run_id}, + ).fetchone() + if row is not None and not honors_cancel(str(row.source)): + logger.warning( + "mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — " + "задача продолжила бы работать под статусом 'cancelled'", + run_id, + row.source, + ) + return False result = db.execute( text( """ UPDATE scrape_runs - SET status = 'cancelled', finished_at = NOW() + SET status = 'cancelled', finished_at = clock_timestamp() WHERE id = :run_id AND status = 'running' RETURNING id """ @@ -366,8 +725,8 @@ def list_all( db.execute( text( f""" - SELECT id AS run_id, source, run_type, status, params, counters, - total_seen, new_count, started_at, finished_at, + SELECT id AS run_id, source, status, params, counters, + ban_kind, total_seen, new_count, started_at, finished_at, heartbeat_at, error AS error_text FROM scrape_runs WHERE {where_sql} @@ -381,3 +740,22 @@ def list_all( .all() ) return total, [dict(r) for r in rows] + + +def distinct_sources(db: Session) -> list[str]: + """Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту). + + #2674: фильтр источников в админке был захардкожен тремя площадками + (avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким + точным значением — все три пункта фильтра давали пустую выдачу, а 76% + прогонов (включая всю площадку Домклик) отфильтровать было нечем. + Список обязан приходить из данных: новый source появляется в фильтре сам, + без правки кода. + + Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из + выпадающего списка все остальные. + """ + rows = db.execute( + text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source") + ).fetchall() + return [str(r.source) for r in rows] diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 2d2d9b4a..2192cd8c 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -66,7 +66,6 @@ class RealMatcherAdapter: *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, source_url: str | None = None, ) -> tuple[int | None, float, str]: # house_id is None when the matcher refuses a numberless address without a @@ -80,7 +79,6 @@ class RealMatcherAdapter: lon, year_built=year_built, building_cadastral_number=building_cadastral_number, - cadastral_number=cadastral_number, source_url=source_url, ) @@ -136,10 +134,6 @@ class RealScraperConfig: def scraper_proxy_url(self) -> str | None: return _settings.scraper_proxy_url - @property - def avito_proxy_rotate_url(self) -> str | None: - return _settings.avito_proxy_rotate_url - @property def avito_proxy_max_rotations(self) -> int: return _settings.avito_proxy_max_rotations @@ -148,10 +142,6 @@ class RealScraperConfig: def avito_serp_ekb_only(self) -> bool: return _settings.avito_serp_ekb_only - @property - def yandex_proxy_rotate_url(self) -> str | None: - return _settings.yandex_proxy_rotate_url - @property def cian_proxy_url(self) -> str | None: return _settings.cian_proxy_url @@ -189,10 +179,6 @@ class RealScraperConfig: def proxy_rotate_attempt_timeout_s(self) -> float: return _settings.proxy_rotate_attempt_timeout_s - @property - def cian_proxy_rotate_url(self) -> str | None: - return _settings.cian_proxy_rotate_url - @property def cian_proxy_max_rotations(self) -> int: return _settings.cian_proxy_max_rotations @@ -219,6 +205,11 @@ class RealScraperConfig: def use_proxy_pool_browser(self) -> bool: return _settings.use_proxy_pool_browser + # ── #2616 шаг 1: признак окружения для отказа вместо мёртвого env-fallback ── + @property + def environment(self) -> str: + return _settings.environment + class RealProxyProvider: """ProxyProvider-адаптер над `app.services.proxy_pool` (#2163). @@ -267,6 +258,20 @@ class RealProxyProvider: finally: db.close() + def touch(self, lease: ProxyLease) -> None: + db = _SessionLocal() + try: + _proxy_pool.touch(db, lease.id) + finally: + db.close() + + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + db = _SessionLocal() + try: + _proxy_pool.mark_banned(db, lease.id, source=source) + finally: + db.close() + class RealSessionFactory: """SessionFactory-адаптер над `app.core.db.SessionLocal`.""" diff --git a/tradein-mvp/backend/app/services/search_query.py b/tradein-mvp/backend/app/services/search_query.py index b7d06ed7..55890449 100644 --- a/tradein-mvp/backend/app/services/search_query.py +++ b/tradein-mvp/backend/app/services/search_query.py @@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]: where.append("total_floors <= CAST(:fl_total_max AS integer)") args["fl_total_max"] = params.floors_total_max - if params.has_kadastr: - where.append("cadastral_number IS NOT NULL") + # Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ) + # пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель, + # парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат + # `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал + # качество данных, которого нет. Колонка и её писатель оставлены: если площадка + # начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр. segment_clause = _SEGMENT_SQL[params.segment] if segment_clause is not None: diff --git a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py index 49958e18..e6220687 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -20,12 +20,20 @@ snapshot_listing_sources / import_rosreestr_dkp. Окно расписания 06:00-07:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы refresh потреблял свежие ДКП-сделки того же дня. -SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в data/sql/080_asking_to_sold_ratios.sql -(deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес -окно, ppm²-полоса [_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), -бакет LEAST(GREATEST(rooms,0),4), порог n_deals>=30 AND n_listings>=30 для per_rooms, -global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым, +SQL derivation ниже повторяет seed в data/sql/080_asking_to_sold_ratios.sql (deal_side / +ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес окно, ppm²-полоса +[_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), порог n_deals>=30 AND +n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым, конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике). + +#2620 — ОДНО ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по +LEAST(GREATEST(rooms,0),4), а ask_side — по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула, +что deals.rooms получает при импорте). Причина — deals.rooms НЕ настоящая комнатность +(Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами +listings значило сравнивать разные классификации. Замер на проде (2026-08, #2620) показал +миграцию 23-55% объявлений между бакетами при таком сравнении — не только в бакете «4+» +(который к тому же обрезан обрезкой ELSE 4, тогда как listings.rooms доходит до 10) — и +это и была причина ratio>1 в бакете 4+ (см. _AREA_ROOMS_BUCKET_SQL ниже). """ from __future__ import annotations @@ -41,17 +49,56 @@ from app.services import scrape_runs as runs_mod # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. _PPM2_MIN: int = 30_000 -# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2 -# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей -# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала -# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global) -# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги — -# заменить на per-city ratio через зарезервированный столбец `district` (#647). +# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а +# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала +# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» −29% системно). +# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по +# ОДНОМУ рынку. +# +# #2583 H2 (аудит, 2026-08): oblast-развёртки заработали 12 июля — областные объявления +# попали в знаменатель (ask_side/ask_global) без городского скоупа, а sold-сторона +# осталась скоуплена на ЕКБ → асимметрия вернулась с другой стороны (дешёвая область +# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены +# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном +# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE +# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный +# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон. _ASKING_CITY_PATTERN: str = "%Екатеринбург%" # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000). # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. +# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ: +# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms +# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы — +# держи границы (30/44/62/85) в синхроне при правке: shell (import-rosreestr.sh) → SQL +# (эта константа, ask_side ниже) → Python (area_bucket() ниже, estimator.py rekey #2620-2). +_AREA_ROOMS_BUCKET_SQL = ( + "CASE WHEN area_m2 < 30 THEN 0 WHEN area_m2 < 44 THEN 1 " + "WHEN area_m2 < 62 THEN 2 WHEN area_m2 < 85 THEN 3 ELSE 4 END" +) + + +def area_bucket(area_m2: float) -> int: + """Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620). + + Используется estimator.py при ПРИМЕНЕНИИ ratio (не только при расчёте здесь) — + ratio_resolver должен ключевать по ТОМУ ЖЕ area-бакету, что и ask_side при + деривации, иначе mismatch просто переезжает из расчёта в применение (прод-замер + ревьюера #2620: 310/1038 = 29.9% исторических запросов легли бы в другой бакет + при rooms-ключе vs area-ключе). + """ + if area_m2 < 30: + return 0 + if area_m2 < 44: + return 1 + if area_m2 < 62: + return 2 + if area_m2 < 85: + return 3 + return 4 + + logger = logging.getLogger(__name__) # ── True-mirror cleanup: drop all #648 rows before re-derivation ────────────── @@ -69,15 +116,18 @@ _DELETE_SQL = text( # deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: # sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr', # ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE − 12 months), -# бакет LEAST(GREATEST(rooms,0),4). +# бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620 +# комментарий у _AREA_ROOMS_BUCKET_SQL выше). # ask_median = percentile_cont(0.5) по listings.price_per_m2 -# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max]). +# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что +# SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет — +# _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там. # per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0. # global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12. # Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max # (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую). _REDERIVE_SQL = text( - """ + f""" WITH -- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). deal_side AS ( @@ -93,19 +143,37 @@ _REDERIVE_SQL = text( AND deal_date >= CURRENT_DATE - INTERVAL '12 months' GROUP BY LEAST(GREATEST(rooms, 0), 4) ), - -- ASKING медианы по бакетам комнат среди ТЕКУЩИХ активных объявлений. + -- ASKING медианы по ТОМУ ЖЕ area-бакету, что deal_side (#2620) — НЕ по listings.rooms. + -- deals.rooms — синтетика из площади (Росреестр её не отдаёт), listings.rooms — реальная + -- комнатность; сравнение area-бакета с area-бакетом (не area-бакета с real-rooms-бакетом) + -- убирает миграцию объявлений между бакетами (23-55% строк на проде, 2026-08, #2620) — + -- включая инверсию ratio>1 в бакете «4+» (deals.rooms обрезан ELSE 4, а listings.rooms + -- нет: 110/782 пяти- и более комнатных объявлений раньше схлопывались в бакет 4). ask_side AS ( SELECT - LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, + {_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket, percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, COUNT(*) AS n_listings FROM listings WHERE is_active AND rooms IS NOT NULL + -- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4) + -- of _AREA_ROOMS_BUCKET_SQL — a latent "everything unmeasured looks like a big + -- flat" trap. Excluded explicitly instead of relying on ELSE-as-junk-drawer. + AND area_m2 IS NOT NULL AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 AND (listing_segment IS NULL OR listing_segment = 'vtorichka') - GROUP BY LEAST(GREATEST(rooms, 0), 4) + -- #2583 H2: скоупим ASKING-сторону на тот же город, что и SOLD-сторона + -- (симметрично deal_side выше) — иначе дешёвые oblast-объявления (развёртки + -- с 12 июля) занижают ask-медиану и завышают ratio. city IS NULL считается + -- "своим" (не отбрасывается) НАМЕРЕННО: listings.city заполнена пока только у + -- Авито (Циан/Домклик/Яндекс — NULL, #2598/#2606), симметричный + -- `city ILIKE :asking_city` без IS NULL выбросил бы ~70% выборки. По мере + -- роста покрытия колонки этот предикат сам ужесточается без правок кода; когда + -- покрытие станет полным — заменить на строго симметричный `city ILIKE :asking_city`. + AND (city IS NULL OR city ILIKE :asking_city) + GROUP BY {_AREA_ROOMS_BUCKET_SQL} ), -- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0. -- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1. @@ -149,9 +217,15 @@ _REDERIVE_SQL = text( FROM listings WHERE is_active AND rooms IS NOT NULL + -- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row + -- population consistent with the per-bucket rows it's a fallback for. + AND area_m2 IS NOT NULL AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- #2583 H2: тот же городской скоуп, что и ask_side выше (см. комментарий там + -- про причину city IS NULL == "свой" и #2598/#2606). + AND (city IS NULL OR city ILIKE :asking_city) ), -- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0). global_row AS ( @@ -210,6 +284,18 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. + LIMITATION (#2620, честно задокументировано — не гард, а факт данных): sold-сторона + (deals) НЕ имеет маркера новостройка/вторичка — Росреестр таким свойством ДКП не + делится, а listing_segment (гард #1186) существует только у listings. ask_side/ask_global + отфильтрованы на вторичку, deal_side/deal_global — нет. Замер на проде (2026-08, #2620): + доля сделок с year_built >= 2020 (грубый прокси новостройки) — 44.1% в бакете «4+» против + 23.1% в бакетах 1-3 — заметный перекос, но year_built НЕ идентифицирует первичку/вторичку + (продажа квартиры 2021 года постройки в 2026м — легитимная вторичка), поэтому фильтр по + году НЕ добавлен (создал бы новую, столь же спекулятивную асимметрию). Area-бакет-фикс + ниже (см. _AREA_ROOMS_BUCKET_SQL) сам по себе убрал инверсию ratio>1 в бакете «4+» + (0.8315 на замере прод-данных 2026-08, было 1.0257) — снятие миграции между бакетами было + root cause, а не новостройки. + Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}. """ counters: dict[str, int] = { diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 5e3374c9..3805b8e0 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -10,8 +10,9 @@ Legacy listings (older than 2h or outside radius) are never enriched. Solution: single snapshot SELECT at start (guarantees termination), same proxy session path as the detail-phase of `run_avito_city_sweep` (scraper_kit.orchestration.pipeline). Block handling mirrors that phase: -rotate IP on every block, abort after max_consecutive_blocks (mark_done not -mark_failed -- block is temporary, retry next night via NULL detail_enriched_at). +rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного +блоками прогона — 'banned' (#2674, runs.mark_backfill_finished): работу он не +доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at. """ from __future__ import annotations @@ -30,6 +31,7 @@ from scraper_kit.avito_exceptions import ( AvitoRateLimitedError, ) from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.orchestration.pipeline import CITY_LOCATIONS # #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше # _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline. @@ -46,6 +48,7 @@ from scraper_kit.providers.avito.detail import ( save_detail_enrichment, ) from scraper_kit.providers.avito.serp import AvitoScraper +from scraper_kit.snapshot_writer import upsert_listing_snapshot from sqlalchemy import text from sqlalchemy.orm import Session @@ -70,6 +73,31 @@ __all__ = [ "run_avito_detail_backfill", ] +# #2576 этап B: oblast-города (region 66, вне ЕКБ) уже дают листинги (Каменск- +# Уральский), но snapshot-SELECT ниже раньше фильтровал ЖЁСТКО '%/ekaterinburg/%' — +# у всех остальных detail_enriched_at оставался NULL навсегда (без detail-страницы +# нет lat/lon -> листинг молча выпадает из подбора аналогов по радиусу). +# CITY_LOCATIONS.avito_slug — единственный источник правды для avito URL-слага +# города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис, +# verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы +# risk дрейфа при добавлении новых oblast-городов. +# +# #2578 review: Postgres LIKE трактует '_' как wildcard "один любой символ" (не +# литерал) и '%' как wildcard "любая последовательность" -- два слага из пяти +# (nizhniy_tagil, verhnyaya_pyshma) содержат '_', без экранирования это латентная +# дыра: город с похожим слагом (напр. nizhniyXtagil) молча совпал бы. Сегодня +# коллизий нет (проверено на проде: raw vs escaped паттерны дают одинаковые 776 +# совпадений), но экранируем сейчас, а не когда появится реальная коллизия. +# LIKE по умолчанию использует '\' как escape-символ (без явного ESCAPE) — +# подтверждено на живом Postgres 16.4 (см. коммит #2578-fixup): 'nizhniyXtagil' +# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ +# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный +# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан. +_OBLAST_AVITO_URL_PATTERNS = tuple( + "%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%" + for loc in CITY_LOCATIONS.values() +) + @dataclass class AvitoDetailBackfillResult: @@ -102,15 +130,22 @@ async def run_avito_detail_backfill( """Backfill detail_enriched_at for legacy avito listings via mobile proxy. Params (from default_params jsonb in scrape_schedules): - batch_size: int -- snapshot size (SELECT LIMIT), default 800. + batch_size: int -- ЕКБ snapshot size (SELECT LIMIT), default 800 (unchanged, + #2576 -- volume/order for ЕКБ stay byte-identical to pre-oblast behaviour). + oblast_batch_size: int -- ДОПОЛНИТЕЛЬНАЯ reserved-квота для листингов + области (#2576), default 100. Отдельный LIMIT, НЕ отъедает от batch_size + ЕКБ -- гарантирует области честную обработку и одновременно не даёт + всплеску свежих oblast-листингов вытеснить ЕКБ из top-N по scraped_at. budget_sec: float -- wall-clock budget per run, default 3600s. request_delay_sec: float -- delay between listings, default 6.0s. max_consecutive_blocks: int -- abort threshold, default 5. Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> - mark_done (incl. partial/block-abort) / mark_failed (exception only). + mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); + mark_failed напрямую — только при исключении. """ batch_size = int(params.get("batch_size", 800)) + oblast_batch_size = int(params.get("oblast_batch_size", 100)) budget_sec = float(params.get("budget_sec", 3600)) request_delay_sec = float(params.get("request_delay_sec", 6.0)) max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5)) @@ -141,7 +176,7 @@ async def run_avito_detail_backfill( # kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) — # RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без - # аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()). + # аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()). scraper = AvitoScraper(RealScraperConfig()) start = time.monotonic() @@ -189,30 +224,59 @@ async def run_avito_detail_backfill( runs_mod.update_heartbeat(db, run_id, current_counters) # SNAPSHOT: single SELECT at start -- NOT re-selected in loop. - # Scope (#1814): только активные ЕКБ-листинги. region_code на insert - # хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный - # признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy - # Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch - # на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта. - # Не тратим фетчи на мёртвые (is_active) и не-ЕКБ. + # Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast- + # городов (region 66). region_code на insert хардкодится в 66 (base.py) → + # НЕ дискриминирует город; реальный признак города у Avito — путь URL + # (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt- + # peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в + # oblast CTE). browser-fetch на legacy не-ЕКБ/не-oblast спотыкается → + # curl-fallback → 429-бан curl-фингерпринта. Не тратим фетчи на мёртвые + # (is_active) и на регионы вне scope. + # + # Два CTE вместо одного WHERE ... OR ...: ekb сохраняет ТОЧНО прежний + # LIMIT/ORDER (#2576 требование "ЕКБ не деградирует") -- oblast НЕ может + # вытеснить ЕКБ из batch_size ни при каком всплеске свежих oblast-строк + # (ORDER BY ... scraped_at DESC в общем WHERE отдал бы приоритет самым + # свежим независимо от города). oblast получает отдельную честную квоту + # oblast_batch_size, добавленную ПОСЛЕ ekb-квоты (не вычтенную из неё). snapshot = ( db.execute( text( """ - SELECT id, source_url - FROM listings - WHERE source = 'avito' - AND detail_enriched_at IS NULL - AND source_url IS NOT NULL - AND is_active = TRUE - AND source_url LIKE '%/ekaterinburg/%' - -- сперва листинги без координат (#1967 — detail-страница даёт - -- координаты здания), затем по свежести - ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST - LIMIT CAST(:batch_size AS int) + WITH ekb AS ( + SELECT id, source_url, price_rub, 'ekb' AS city_scope + FROM listings + WHERE source = 'avito' + AND detail_enriched_at IS NULL + AND source_url IS NOT NULL + AND is_active = TRUE + AND source_url LIKE '%/ekaterinburg/%' + -- сперва листинги без координат (#1967 — detail-страница + -- даёт координаты здания), затем по свежести + ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST + LIMIT CAST(:batch_size AS int) + ), + oblast AS ( + SELECT id, source_url, price_rub, 'oblast' AS city_scope + FROM listings + WHERE source = 'avito' + AND detail_enriched_at IS NULL + AND source_url IS NOT NULL + AND is_active = TRUE + AND source_url LIKE ANY(CAST(:oblast_patterns AS text[])) + ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST + LIMIT CAST(:oblast_batch_size AS int) + ) + SELECT id, source_url, price_rub, city_scope FROM ekb + UNION ALL + SELECT id, source_url, price_rub, city_scope FROM oblast """ ), - {"batch_size": batch_size}, + { + "batch_size": batch_size, + "oblast_patterns": list(_OBLAST_AVITO_URL_PATTERNS), + "oblast_batch_size": oblast_batch_size, + }, ) .mappings() .all() @@ -227,11 +291,16 @@ async def run_avito_detail_backfill( runs_mod.mark_done(db, run_id, current_counters) return counters + # #2576: разбивка ekb/oblast только для наблюдаемости -- .get() консервативен + # (city_scope нет в mock-снапшотах старых тестов, дефолт "ekb" их не ломает). + oblast_count = sum(1 for row in snapshot if row.get("city_scope") == "oblast") logger.info( - "avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs " - "delay=%.1fs max_blocks=%d mode=%s)", + "avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, " + "budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)", run_id, len(snapshot), + len(snapshot) - oblast_count, + oblast_count, budget_sec, request_delay_sec, max_consecutive_blocks, @@ -239,6 +308,7 @@ async def run_avito_detail_backfill( ) consecutive_blocks = 0 + aborted_by_blocks = False do_sleep = False items_since_warm = 0 @@ -376,6 +446,21 @@ async def run_avito_detail_backfill( text("UPDATE listings SET is_active = FALSE WHERE id = :id"), {"id": row["id"]}, ) + # #2674: 404 с площадки — самый достоверный сигнал снятия, + # фиксируем его в дневной истории (listings_snapshots.status + # был константой 'active' у всех строк, 394 299). Тот же + # SAVEPOINT, что и UPDATE флага: снимок без флага (или + # наоборот) невозможен. price_rub из snapshot-SELECT — + # .get() консервативен ради mock-снапшотов старых тестов. + gone_price = row.get("price_rub") + if gone_price is not None: + upsert_listing_snapshot( + db, + listing_id=row["id"], + price_rub=gone_price, + run_id=run_id, + status="closed", + ) except Exception: logger.warning( "avito_detail_backfill: run_id=%d failed to mark listing %s " @@ -416,6 +501,7 @@ async def run_avito_detail_backfill( counters.enriched, counters.attempted, ) + aborted_by_blocks = True break # МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено: # 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную @@ -488,9 +574,15 @@ async def run_avito_detail_backfill( counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() - runs_mod.mark_done(db, run_id, current_counters) + runs_mod.mark_backfill_finished( + db, + run_id, + current_counters, + source="avito_detail_backfill", + aborted_by_blocks=aborted_by_blocks, + ) logger.info( - "avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " + "avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "blocked=%d gone=%d failed=%d duration=%.1fs", run_id, counters.attempted, diff --git a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py index 035f7be1..286d1cf8 100644 --- a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py @@ -10,6 +10,26 @@ Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер), работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28». +Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` — +EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом +области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки +города такой листинг получает екатеринбургские координаты, хотя находится в другом городе. +Гейт — ДВЕ проверки перед вызовом _geoportal_house_match: + 1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом, + листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из + контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается + на то, что город явно упомянут в самом тексте адреса. + 2. _names_non_ekb_city(address) (та же функция, что гейтит EKB-only тиры внутри + geocoder.geocode()) — СОХРАНЕНА как fallback для листингов, у которых city IS NULL + (записаны до миграции 196 или путём, ещё не проставляющим город, например admin + ad-hoc /admin/scrape) — там единственный сигнал о городе — текст адреса. +Оба пути пропуска считаются в skipped_non_ekb (адрес остаётся lat IS NULL для +geocode_missing_listings, oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC). +Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно — +это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый +non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за +ночь) — лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585). + Запуск: python -m app.tasks.backfill_listings_coords_geoportal python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200 @@ -19,7 +39,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex, coarse city-centroid fallback): точный house-level матч должен получить шанс первым, иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL -(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). +(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским +гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь +вообще, поэтому «победа в гонке» больше не портит их координаты — они просто ждут +geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча. + +geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому +_update_listing_coords НЕ проставляет geo_precision — он остаётся NULL, что в текущей +конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse» +(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings). +Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие +строки — исключать нужно только 'city'-fallback, а не «пока не размечено». Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL). Повторный прогон не затирает уже проставленные координаты. @@ -37,7 +67,7 @@ from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services import scrape_runs as runs_mod -from app.services.geocoder import _geoportal_house_match, _parse_street_house +from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house logger = logging.getLogger(__name__) @@ -55,6 +85,17 @@ class BackfillCoordsResult: updated: int = 0 # реально обновлено (UPDATE rowcount) no_address: int = 0 # listing.address IS NULL / не распарсился no_match: int = 0 # адрес распарсился, но в реестре здания нет + skipped_non_ekb: int = 0 # non-ЕКБ гейт ВСЕГО: колонка city (#2594) ИЛИ текст (#2583) + # Подмножество skipped_non_ekb — только те, кого отсёк гейт по КОЛОНКЕ city + # (#2603). Зачем отдельный счётчик: колоночный гейт стоит ПЕРЕД парсером + # адреса, поэтому по мере раскатки областных развёрток (#2598) строки, которые + # сейчас падают в no_address (город неизвестен, адрес не парсится), начнут + # перетекать в skipped_non_ekb — и общий счётчик поменяет смысл ровно тогда, + # когда по нему хотят валидировать раскатку. Разность + # skipped_non_ekb - skipped_non_ekb_by_column = вклад ТЕКСТОВОГО гейта, т.е. + # старая метрика #2583 остаётся вычислимой. Обратная совместимость: + # skipped_non_ekb продолжает означать то же, что и раньше (гейт целиком). + skipped_non_ekb_by_column: int = 0 errors: int = 0 # исключения при обработке отдельной записи duration_sec: float = field(default=0.0) @@ -65,6 +106,8 @@ class BackfillCoordsResult: "updated": self.updated, "no_address": self.no_address, "no_match": self.no_match, + "skipped_non_ekb": self.skipped_non_ekb, + "skipped_non_ekb_by_column": self.skipped_non_ekb_by_column, "errors": self.errors, "duration_sec": int(self.duration_sec), } @@ -153,7 +196,7 @@ def backfill_coords_from_geoportal( rows = ( db.execute( text(f""" - SELECT id, address + SELECT id, address, city FROM listings WHERE lat IS NULL AND geom IS NULL @@ -184,6 +227,32 @@ def backfill_coords_from_geoportal( res.no_address += 1 continue + # Городской гейт по колонке (#2594 шаг 2/3) — ПЕРЕД матчем и ПЕРЕД + # текстовым гейтом. listings.city (миграция 196) проставляется из + # контекста развёртки скрапером — надёжнее текста адреса. Голый + # тагильский адрес без города в тексте ("ул. Победы, 30") раньше + # проходил только текстовый гейт и мог ложно сматчиться с + # одноимённым екатеринбургским домом в EKB-only реестре. Это окно + # идёт ПЕРЕД geocode_missing_listings — без гейта по колонке оно + # успевает испортить координаты первым. + city: str | None = row.get("city") + if city is not None and city != "Екатеринбург": + res.skipped_non_ekb += 1 + # Отдельный срез (#2603) — общий skipped_non_ekb смешивает + # колоночный и текстовый гейты, а по мере раскатки #2598 + # колоночный будет забирать строки из no_address. + res.skipped_non_ekb_by_column += 1 + continue + + # Текстовый гейт (#2583, H3) — fallback для листингов, у которых + # колонка city пуста (записаны до миграции 196 либо путём, ещё не + # проставляющим город, напр. admin ad-hoc /admin/scrape). Адрес, + # явно называющий другой город региона, пропускаем — остаётся + # lat IS NULL для oblast-aware geocode_missing_listings. + if _names_non_ekb_city(address): + res.skipped_non_ekb += 1 + continue + # Парсинг адреса — переиспользуем парсер geocoder'а parsed = _parse_street_house(address) if parsed is None: @@ -259,12 +328,15 @@ def backfill_coords_from_geoportal( logger.info( "backfill_coords: DONE — candidates=%d matched=%d updated=%d " - "no_address=%d no_match=%d errors=%d duration=%.1fs", + "no_address=%d no_match=%d skipped_non_ekb=%d (by_column=%d) " + "errors=%d duration=%.1fs", res.candidates, res.matched, res.updated, res.no_address, res.no_match, + res.skipped_non_ekb, + res.skipped_non_ekb_by_column, res.errors, res.duration_sec, ) @@ -314,13 +386,16 @@ def run_geoportal_coords_backfill( runs_mod.mark_done(db, run_id, counters) logger.info( "run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d " - "updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs", + "updated=%d no_address=%d no_match=%d skipped_non_ekb=%d (by_column=%d) " + "errors=%d duration=%.1fs", run_id, res.candidates, res.matched, res.updated, res.no_address, res.no_match, + res.skipped_non_ekb, + res.skipped_non_ekb_by_column, res.errors, res.duration_sec, ) @@ -376,12 +451,13 @@ def main() -> None: logger.info( "Готово: кандидатов=%d сматчено=%d обновлено=%d " - "без_адреса=%d без_матча=%d ошибок=%d время=%.1fs", + "без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs", result.candidates, result.matched, result.updated, result.no_address, result.no_match, + result.skipped_non_ekb, result.errors, result.duration_sec, ) diff --git a/tradein-mvp/backend/app/tasks/cadastral_geo_match.py b/tradein-mvp/backend/app/tasks/cadastral_geo_match.py index 3bb58c55..adc21d50 100644 --- a/tradein-mvp/backend/app/tasks/cadastral_geo_match.py +++ b/tradein-mvp/backend/app/tasks/cadastral_geo_match.py @@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment): This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest cadastral building within `threshold_m`, NOT necessarily its exact cadastral building. The threshold is always logged. Exact cadastral resolution + parcel-containment are - deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already - treats building_cadastral_number as a hint, not ground truth, so an approximate fill is - a net win over 0% coverage. + deferred (cad_parcels FDW not exposed). + + ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке). + Раньше здесь было написано, что Tier-0 матчинга домов «уже трактует + building_cadastral_number как подсказку»; это неверно — Tier 0 в + `app/services/matching/houses.py` отдаёт confidence 1.0, т.е. точное совпадение. + Замер на проде 2026-08-05: 656 из 3 260 заполненных здесь значений накрывают более + одного здания ГАР (20.1%), а 751 из 2 864 зданий ГАР получают более одного значения + (26.2%) — ключ не инъективен ни в одну сторону. Поэтому подавать эту колонку в Tier 0 + (ни на пере-скрейпе, ни бэкфиллом в houses.cadastral_number) НЕЛЬЗЯ: это склеит разные + здания с максимальной уверенностью. Колонка годится как признак/подсказка, не как + идентичность здания. Pipeline (one combined run, scheduler source='cadastral_geo_match'): 1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan). diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py index d76f2be9..b6c5907b 100644 --- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py +++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py @@ -18,6 +18,14 @@ Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column). Rate limit: scraper_settings.get_scraper_delay('cian') between requests. + +Сигнал живости (#2725): батч дёргает `on_progress` на КАЖДОЙ сущности — caller +переливает это в scrape_runs.heartbeat_at. Пока колбэка не было, планировщик слал +heartbeat один раз ДО батча, а `reap_zombies` меряет ровно heartbeat с порогом 6 ч — +и добивал живые прогоны строго на 6-м часу (6 прод-прогонов, у пятерых внутри окна +писались строки offer_price_history, у одного — до 5.4 ч после старта). Ослаблять +критерий нельзя: пометка 'zombie' снимает running-блокировку источника +(`has_running_run`), без неё зависший прогон запер бы источник навсегда. """ from __future__ import annotations @@ -25,6 +33,7 @@ from __future__ import annotations import asyncio import logging import time +from collections.abc import Callable from dataclasses import dataclass, field from scraper_kit.browser_fetcher import BrowserFetcher @@ -68,6 +77,7 @@ async def backfill_cian_history( do_houses: bool = True, do_valuations: bool = False, dry_run: bool = False, + on_progress: Callable[[CianBackfillResult], None] | None = None, ) -> CianBackfillResult: """Iterate Cian listings + houses with missing history, fetch+save. @@ -82,6 +92,10 @@ async def backfill_cian_history( do_valuations: process Cian Valuation Calculator batch (external_valuations backfill). Default False — opt-in because each call hits Cian auth-gated API. dry_run: skip all fetch+save; only count and log pending rows. + on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из + трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет + heartbeat; исключения колбэка — на его совести (планировщик глушит их сам), + здесь они прервали бы батч. Returns: CianBackfillResult with per-domain counters + total wall-clock duration. @@ -120,6 +134,8 @@ async def backfill_cian_history( listing_id: int = row["id"] source_url: str = row["source_url"] result.listings_processed += 1 + if on_progress is not None: + on_progress(result) enrichment = None try: @@ -209,6 +225,8 @@ async def backfill_cian_history( house_id: int = hrow["id"] zhk_url: str = hrow["cian_zhk_url"] result.houses_processed += 1 + if on_progress is not None: + on_progress(result) enrichment = None try: @@ -291,6 +309,8 @@ async def backfill_cian_history( else: for row in rows: result.valuations_processed += 1 + if on_progress is not None: + on_progress(result) try: cval = await estimate_via_cian_valuation( db, diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 1d1c2188..1f800160 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -9,6 +9,9 @@ TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - Строки НЕ удаляются -- история нужна для бэктеста (#667). +- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом + 'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка + ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого. Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*), @@ -41,21 +44,126 @@ logger = logging.getLogger(__name__) # только реальный скрейп). _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) +# ── Снимок «протухло» в дневной истории (#2674) ─────────────────────────────── +# listings_snapshots.status до этого фикса был константой 'active' у всех строк +# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали +# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация +# TTL-задачей не оставляла в истории вообще никакого следа. Из-за этого дата снятия +# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из +# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни. +# +# ПОЧЕМУ 'stale', А НЕ 'closed'. Эта задача НЕ знает, что объявление снято, — она +# знает только, что МЫ его N суток не видели, а это разные факты, когда TTL короче +# простоя обхода. Замер: прогон по домклику 02.08 снял 6131 объявление за раз (TTL +# 14 суток против 12 суток простоя обхода) — с общим статусом это были бы 6131 +# фальшивая «дата продажи» одной датой. Продукт про цены, смешивать факт с догадкой +# дорого. Поэтому: +# 'closed' — только путь 404: площадка ответила «нет» (avito_detail_backfill); +# 'stale' — этот путь: «мы N суток не смотрели». +# Дата всё равно фиксируется, но читатель отличает одно от другого. Ограничения +# CHECK на колонке нет (проверено на проде), миграция не нужна — только COMMENT. +# +# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или +# наоборот) невозможна по построению — один statement, data-modifying CTE. +# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно +# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются +# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных. +# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена. +# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром, +# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем. +_STALE_SNAPSHOT_TAIL = """ + INSERT INTO listings_snapshots + (listing_id, snapshot_date, run_id, price_rub, status, observed_at) + SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'stale', NOW() + FROM stale + ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET + status = 'stale', + observed_at = EXCLUDED.observed_at +""" + + +# ── Гейт по здоровью сбора (#2659) ──────────────────────────────────────────── +# TTL отвечает на вопрос «объявление сняли?», а меряет «мы его давно не видели». +# Пока обход здоров, разница мала. Когда обход лёг — разница равна всему инвентарю. +# +# Замер на проде, из-за которого этот гейт существует. Авито 10.07-26.07.2026: +# 17 суток подряд без единой успешно собранной страницы, TTL=10 снял за этот отрезок +# 9 033 строки; 1 270 из них потом доказанно вернулись живыми (снимки +# listing_source_snapshots + текущий last_seen_at) — сбор восстановился, и объявления +# оказались на месте. То есть «мы не смогли зайти» было прочитано как «объявление снято». +# +# ПОЧЕМУ НЕ ПО СТАТУСУ ban. Соблазн взять scrape_runs.status='banned' — ловушка: +# Яндекс 18.07-30.07 — 5 прогонов в сутки, ВСЕ 'done', НОЛЬ 'banned', total_seen=0 +# 13 суток подряд (снято ~839 строк vtorichka); +# Домклик 20.07-30.07 — то же самое, 11 суток 'done' с total_seen=0, а 02.08 TTL +# снял 6 131 строку разом (см. комментарий про 'stale' выше). +# Оба провала для ban-детектора невидимы. Поэтому здоровье меряем НЕ статусом прогона, +# а результатом: сколько строк источник реально подтвердил свежими за последние сутки. +# +# МЕТРИКА: count(*) по той же колонке свежести, что и сам TTL (last_seen_at или +# scraped_at) и по тому же срезу source+segment, что и UPDATE. Одна колонка на обе +# стороны — гейт нельзя обмануть bulk-touch'ем, который не двигает scraped_at (#2204). +# +# ПОРОГ. Ряд «подтверждений за 3 суток» по дням (восстановлен из listing_source_snapshots): +# avito здоровые сутки 3542..6079, провал 10.07-26.07 — 0..970 → порог 1500; +# yandex vtorichka здоровые 897..2206, провал — 0 → порог 500; +# cian vtorichka 748..4329, провала не было → порог 500; +# domklik по scraped_at сейчас 62/3 суток (сбор фактически стоит) → порог 200. +# Пороги живут в default_params расписания (миграция 219), здесь только страховка +# на случай незасеянного расписания. Асимметрия цены ошибки намеренная: пропущенная +# деактивация чинится следующим прогоном, ложная — только повторным сбором, которого +# может не быть. Поэтому при сомнении — пропускаем прогон. +# +# ПОТОЛОК: окно 3 суток годится, пока свипы источника ходят не реже чем раз в 3 дня. +# Источник с более редкой каденцией будет блокироваться всегда — тогда окно нужно +# растить до каденции, а не понижать порог. +_HEALTH_WINDOW_DAYS = 3 + +# Страховка для расписаний без явного min_confirmations в default_params: ловит +# полный ноль и близкое к нулю, но НЕ ловит частичный провал вроде avito 936-970 — +# для этого нужен посчитанный по источнику порог из миграции 219. +DEFAULT_MIN_CONFIRMATIONS = 500 + +_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))" + + +def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any: + """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE. + + staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. + Значения (:listing_source, :health_window_days, :segments) — param-binding, + psycopg v3 safe (CAST(... AS ...), никаких :param::type). + """ + segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else "" + return text( + f""" + SELECT count(*) + FROM listings + WHERE source = :listing_source + AND {staleness_column} + > NOW() - CAST(:health_window_days || ' days' AS interval){segment_filter} + """ + ) + def _build_all_segments_sql(staleness_column: str) -> Any: """UPDATE без фильтра по сегменту: все сегменты для данного source. staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings, поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source, - :ttl_days) остаются param-binding — psycopg v3 safe (никаких :param::type). + :ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type). """ return text( f""" - UPDATE listings - SET is_active = false - WHERE source = :listing_source - AND is_active = true - AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + WITH stale AS ( + UPDATE listings + SET is_active = false + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + RETURNING id, price_rub + ) + {_STALE_SNAPSHOT_TAIL} """ ) @@ -68,12 +176,16 @@ def _build_segments_sql(staleness_column: str) -> Any: """ return text( f""" - UPDATE listings - SET is_active = false - WHERE source = :listing_source - AND is_active = true - AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) - AND listing_segment = ANY(CAST(:segments AS text[])) + WITH stale AS ( + UPDATE listings + SET is_active = false + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + AND listing_segment = ANY(CAST(:segments AS text[])) + RETURNING id, price_rub + ) + {_STALE_SNAPSHOT_TAIL} """ ) @@ -95,6 +207,8 @@ def deactivate_stale_listings( ttl_days: int, segments: list[str] | None = None, staleness_column: str = "last_seen_at", + min_confirmations: int = 0, + health_window_days: int = _HEALTH_WINDOW_DAYS, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -109,11 +223,20 @@ def deactivate_stale_listings( last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch двигает last_seen_at всем строкам одним timestamp, поэтому честная свежесть = scraped_at (двигается только реальным скрейпом). + min_confirmations: гейт по здоровью сбора (#2659). Сколько строк источник + должен был подтвердить свежими за health_window_days суток, чтобы + деактивации вообще разрешалось исполниться. 0 -> гейт выключен (так + вызывают старые тесты и совместимая обёртка); реальные значения приходят + из default_params расписания, см. миграцию 219 и комментарий выше. + health_window_days: окно подтверждений для гейта, суток. Дефолт 3. Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). - Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed). + Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots + (data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed). - Returns {"deactivated": N} -- количество обновлённых строк. + Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). + Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, + "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Raises: ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, @@ -131,6 +254,44 @@ def deactivate_stale_listings( f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" ) + # Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима + # на практике (вернуть «живость» может только повторный сбор), поэтому + # проверяем ПЕРЕД записью, а не откатываем после. + if min_confirmations > 0: + health_params: dict[str, Any] = { + "listing_source": listing_source, + "health_window_days": health_window_days, + } + if segments is not None: + health_params["segments"] = segments + confirmations = ( + db.execute( + _build_confirmations_sql(staleness_column, with_segments=segments is not None), + health_params, + ).scalar() + or 0 + ) + counters["confirmations"] = int(confirmations) + if confirmations < min_confirmations: + counters["skipped_unhealthy"] = 1 + # Ничего не писали (был только SELECT) — rollback закрывает транзакцию + # чисто, чтобы mark_done стартовал со своей. + db.rollback() + runs_mod.mark_done(db, run_id, counters) + logger.warning( + "deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — " + "подтверждений за %d сут %d < порога %d " + "(segments=%r, staleness_column=%s); ни одна строка не тронута", + listing_source, + run_id, + health_window_days, + confirmations, + min_confirmations, + segments, + staleness_column, + ) + return counters + # segments is None -> все сегменты (поведение avito). segments=[...] -> только # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), @@ -140,10 +301,15 @@ def deactivate_stale_listings( "listing_source": listing_source, "ttl_days": ttl_days, "segments": segments, + "run_id": run_id, } result = db.execute(_build_segments_sql(staleness_column), params) else: - params = {"listing_source": listing_source, "ttl_days": ttl_days} + params = { + "listing_source": listing_source, + "ttl_days": ttl_days, + "run_id": run_id, + } result = db.execute(_build_all_segments_sql(staleness_column), params) counters["deactivated"] = result.rowcount or 0 diff --git a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py new file mode 100644 index 00000000..5e2322f9 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py @@ -0,0 +1,161 @@ +"""Daily recompute of per-city ppm² plausible-deal guard-bands (#2576 Stage B). + +ПРОБЛЕМА: deal_city_price_bands (migration 178, tier-схема — migration 194) +засеяна ON CONFLICT DO UPDATE derivation-запросом. По мере ночного импорта новых +ДКП-сделок (rosreestr_dkp_import) города переходят между tier ('region_fallback' +N<10 → 'rough' N 10-29 → 'full' N>=30), а перцентили внутри tier дрейфуют — нужен +периодический пересчёт по той же derivation. + +Задача синхронная (DB-only, никаких внешних HTTP-вызовов) — запускается +kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh +(run_in_executor), по образцу asking_to_sold_ratio.py / snapshot_listing_sources. + +Окно расписания 07:00-08:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC) И +asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же +свежему срезу deals, что и ratio-таблица того же дня. + +SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в +data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered: +трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см. +комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов). + +Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror +паттерна) — множество городов монотонно растёт (rosreestr_dkp_import только +INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city +(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто +перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <> +'Екатеринбург') — estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 +для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён). +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ───────────────────────────────── +_REDERIVE_SQL = text( + """ + WITH region_stats AS ( + SELECT GREATEST( + round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, + 8000 + ) AS region_ppm2_min + FROM deals + WHERE source = 'rosreestr' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND city <> 'Екатеринбург' + ), + city_stats AS ( + SELECT + city, + GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000) + AS ppm2_p1, + LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000) + AS ppm2_p99, + count(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND city <> 'Екатеринбург' + GROUP BY city + ), + tiered AS ( + SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals, + 'full'::text AS tier + FROM city_stats + WHERE n_deals >= 30 + AND ppm2_p99 >= 8000 + + UNION ALL + + SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals, + 'rough'::text AS tier + FROM city_stats + WHERE n_deals BETWEEN 10 AND 29 + + UNION ALL + + SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals, + 'region_fallback'::text AS tier + FROM city_stats c + CROSS JOIN region_stats r + WHERE c.n_deals < 10 + ) + INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at) + SELECT city, ppm2_min, ppm2_max, n_deals, tier, now() + FROM tiered + ON CONFLICT (city) DO UPDATE + SET ppm2_min = EXCLUDED.ppm2_min, + ppm2_max = EXCLUDED.ppm2_max, + n_deals = EXCLUDED.n_deals, + tier = EXCLUDED.tier, + refreshed_at = EXCLUDED.refreshed_at + """ +) + +# ── Post-insert counters ────────────────────────────────────────────────────── +_COUNTERS_SQL = text( + """ + SELECT + COUNT(*) AS rows_written, + COUNT(*) FILTER (WHERE tier = 'full') AS full_rows, + COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows, + COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows + FROM deal_city_price_bands + """ +) + + +def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]: + """Пересчитать deal_city_price_bands (#2576 Stage B — tier-aware refresh). + + Sync (вызывается scheduler-триггером в executor, как recompute_asking_to_sold_ratios). + Одна транзакция: re-derive INSERT ... ON CONFLICT DO UPDATE (нет DELETE — см. + module docstring), затем counters из таблицы, commit, mark_done. + + Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. + + Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}. + """ + counters: dict[str, int] = { + "rows_written": 0, + "full_rows": 0, + "rough_rows": 0, + "region_fallback_rows": 0, + } + try: + db.execute(_REDERIVE_SQL) + + row = db.execute(_COUNTERS_SQL).mappings().first() + if row is not None: + counters["rows_written"] = int(row["rows_written"] or 0) + counters["full_rows"] = int(row["full_rows"] or 0) + counters["rough_rows"] = int(row["rough_rows"] or 0) + counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0) + + db.commit() + runs_mod.mark_done(db, run_id, counters) + logger.info( + "refresh_deal_city_price_bands run_id=%d done: " + "rows_written=%d full=%d rough=%d region_fallback=%d", + run_id, + counters["rows_written"], + counters["full_rows"], + counters["rough_rows"], + counters["region_fallback_rows"], + ) + return counters + except Exception as exc: + logger.exception("refresh_deal_city_price_bands run_id=%d failed", run_id) + db.rollback() + runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) + raise diff --git a/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py b/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py index b585551c..db7440c1 100644 --- a/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py @@ -142,7 +142,9 @@ def check_deals_freshness( row = db.execute(_LATEST_DEAL_DATE_SQL).first() latest: date | None = row.latest if row is not None else None if latest is None: - logger.warning( + # ERROR (#2674): монитор не может выполнить работу — сбой, а не наблюдение. + # Соседняя ветка (overdue) писала ERROR с самого начала; эта расходилась. + logger.error( "deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя" ) runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 319e9f95..24d63565 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -36,10 +36,11 @@ one BrowserFetcher is constructed per run. Exception triad differs from Avito: - DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) -- - increments consecutive_blocks, abort via mark_done (NOT mark_failed) once - max_consecutive_blocks is hit -- a block-abort is an expected operational - outcome (QRATOR reputation burn), not a task failure. Mirrors Avito's - AvitoBlockedError handling. No IP-rotation/cooldown recovery step exists here + increments consecutive_blocks, abort once max_consecutive_blocks is hit. + Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished): + блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял + mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными. + No IP-rotation/cooldown recovery step exists here (DomClick uses one dedicated residential proxy, not a rotating pool) -- an aborted run simply retries the remaining backlog next window. - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a @@ -52,10 +53,16 @@ Exception triad differs from Avito: Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run. If None (no valid session uploaded / expired) -- the run still proceeds (cookie- injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin -navigation from PR #2430 still applies) but a warning is logged once at run start so +navigation from PR #2430 still applies) but an ERROR is logged once at run start so operators notice the test-account session needs refreshing via `POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation, see app/services/domclick_session.py module docstring). + +#2674: раньше это был WARNING, который в скрапер-контейнере событием не становится +(LoggingIntegration event_level=ERROR) — куки протухли 2026-08-03 и об этом никто не +узнал. Теперь два сигнала вместо одного: ERROR по факту (_alert_domclick_cookies) и +ERROR ЗАРАНЕЕ, пока куки ещё живы (_warn_before_domclick_cookies_expire) — по образцу +#2658 для Циана, ручное обновление кук требует запаса времени. """ from __future__ import annotations @@ -65,6 +72,7 @@ import logging import random import time from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError @@ -85,6 +93,60 @@ __all__ = [ ] +def _alert_domclick_cookies(db: Session, run_id: int) -> None: + """Громкий сигнал «обогащение идёт без кук» — logger.error, не warning (#2674). + + В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR) + (scheduler_main.py), поэтому прежний WARNING событием не становился: куки протухли + на проде 2026-08-03, и единственным следом была строка в docker-логе, которая + теряется при редеплое. Прогон при этом НЕ прерываем — cookie-инъекция это + механизм обхода QRATOR, а не жёсткое требование (см. докстринг модуля), — но + состояние требует ручного действия человека, значит должно быть событием. + + Причину различаем так же, как #2658 у Циана: «кук нет вовсе» и «протухли N дней + назад» лечатся одинаково, но диагностируются по-разному. + """ + expires_at = domclick_session_svc.session_expires_at(db) + now = datetime.now(tz=UTC) + if expires_at is None: + detail = "кук DomClick нет в БД" + elif expires_at <= now: + detail = ( + f"куки DomClick протухли {expires_at:%Y-%m-%d} " + f"({(now - expires_at).days} дн. назад)" + ) + else: + detail = "куки DomClick помечены невалидными (last_invalid_at)" + logger.error( + "domclick_detail_backfill: run_id=%d — %s; обогащение идёт БЕЗ cookie-инъекции " + "(QRATOR-обход деградировал до organic SERP-origin навигации, PR #2430). " + "Перезалейте сессию test-аккаунта: POST /scrape/domclick/upload-cookies", + run_id, + detail, + ) + + +def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None: + """Предупредить ЗАРАНЕЕ, пока куки ещё рабочие (#2674, образец — #2658 для Циана). + + Сигнал по факту протухания приходит, когда обогащение уже встало; обновление кук + ручное, человеку нужен запас. valid_only=True — срок ИМЕННО той записи, которую + взял load_session (при нескольких аккаунтах свежайшая-любая может быть чужой). + """ + expires_at = domclick_session_svc.session_expires_at(db, valid_only=True) + if expires_at is None: + return + left = expires_at - datetime.now(tz=UTC) + if left <= timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS): + logger.error( + "domclick_detail_backfill: run_id=%d — куки DomClick протухнут %s " + "(осталось %.1f дн.); обновите заранее, иначе обогащение деградирует молча", + run_id, + expires_at.date().isoformat(), + left.total_seconds() / 86400, + ) + + @dataclass class DomClickDetailBackfillResult: """Counters for one backfill run.""" @@ -120,7 +182,8 @@ async def run_domclick_detail_backfill( max_consecutive_blocks: int -- abort threshold, default 3. Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> - mark_done (incl. partial/block-abort) / mark_failed (exception only). + mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); + mark_failed напрямую — только при исключении. """ batch_size = int(params.get("batch_size", 200)) budget_sec = float(params.get("budget_sec", 3600)) @@ -135,16 +198,13 @@ async def run_domclick_detail_backfill( try: # Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every - # fetch_detail() call below. None is a valid (degraded) state, not an error. + # fetch_detail() call below. Прогон продолжается и без кук (см. докстринг), но + # это состояние требует ЧЕЛОВЕКА: обновление сессии — ручная операция. cookies = domclick_session_svc.load_session(db) if cookies is None: - logger.warning( - "domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies " - "in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to " - "organic SERP-origin navigation only, PR #2430). Refresh test-account " - "session via POST /scrape/domclick/upload-cookies.", - run_id, - ) + _alert_domclick_cookies(db, run_id) + else: + _warn_before_domclick_cookies_expire(db, run_id) runs_mod.update_heartbeat(db, run_id, current_counters) @@ -193,6 +253,7 @@ async def run_domclick_detail_backfill( ) consecutive_blocks = 0 + aborted_by_blocks = False do_sleep = False # Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see @@ -275,6 +336,7 @@ async def run_domclick_detail_backfill( counters.enriched, counters.attempted, ) + aborted_by_blocks = True break except Exception as e: @@ -296,9 +358,15 @@ async def run_domclick_detail_backfill( counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() - runs_mod.mark_done(db, run_id, current_counters) + runs_mod.mark_backfill_finished( + db, + run_id, + current_counters, + source="domclick_detail_backfill", + aborted_by_blocks=aborted_by_blocks, + ) logger.info( - "domclick_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " + "domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "blocked=%d failed=%d duration=%.1fs", run_id, counters.attempted, diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 2dcfd9f2..e93d0289 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -5,15 +5,29 @@ - Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110) — wired into in-app scheduler, window 06:00-09:00 UTC. -Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings). -Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set. +Pattern: dedup по паре (address, city) — 1 уникальная пара → 1 geocode call → UPDATE +всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется +скрапером из контекста развёртки — один и тот же текст адреса в разных городах +(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а +не схлопываться в один geocode-вызов и один UPDATE по тексту адреса). +Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder). + +SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита +мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без is_active — +`ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида +«Новосибирская обл.,Новосибирск» — сотни listings, у реального адреса — 1-2), поэтому +весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело +не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного +geocode НЕ фильтрует is_active — см. комментарии у соответствующих UPDATE ниже. Отличие от /admin/geocode-missing (per-ID): - - Этот модуль группирует по address → меньше API calls (dedup). + - Этот модуль группирует по (address, city) → меньше API calls (dedup), но не + схлопывает разные города с одинаковым текстом адреса. - Поддерживает all sources включая Avito (после PR #487 убрали jitter). - Возвращает GeocodeBackfillResult с детальными counters. - Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days; - при geocode failure помечает tried_at=NOW() → адрес не переотбирается в этом же run. + при geocode failure помечает tried_at=NOW() → пара (address, city) не + переотбирается в этом же run. """ from __future__ import annotations @@ -27,7 +41,7 @@ from sqlalchemy.orm import Session from app.services import scrape_runs as runs_mod from app.services.estimator import _geocode_is_coarse -from app.services.geocoder import geocode +from app.services.geocoder import geocode, known_city_hint logger = logging.getLogger(__name__) @@ -53,13 +67,24 @@ async def geocode_missing_listings( """Geocode listings с NULL coords (любой source). Steps: - 1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL - GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size - (приоритет адресам с большим числом listings — больший ROI per geocode call) + 1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active + AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC + LIMIT batch_size + (приоритет парам address+city с большим числом listings — больший ROI per + geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3: + один и тот же текст адреса в разных городах — разные записи. `is_active` — + #2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда + не попадут в выдачу пользователю) - 2. Для каждого address: - - geocode(address, db) — auto-cache (hit или miss) - - Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL + 2. Для каждой пары (address, city): + - geocode(address, db, city_hint=known_city_hint(city)) — auto-cache + (hit или miss); хинт гейтится словарём городов области (#2603) + - Если есть результат: UPDATE listings SET lat, lon + WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL + (IS NOT DISTINCT FROM, а не `=` — стандартная SQL NULL-семантика: `city = NULL` + никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы + вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как + совпадение, оставаясь строгим при непустом city — нужная нам симметрия) - PostGIS trigger (listings_set_geom_trg) автоматически обновит geom 3. Log progress каждые 50 addresses. @@ -74,25 +99,39 @@ async def geocode_missing_listings( start = time.monotonic() result = GeocodeBackfillResult() - # 1. Найти top-N адресов с NULL coords (DESC by occurrence count). - # Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены + # 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count). + # Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же + # текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) + # это разные записи с разными координатами, их нельзя схлопывать в один + # geocode-вызов. GROUP BY address, city трактует NULL city как отдельную + # группу (стандартная SQL-семантика группировки NULL как равных друг другу). + # Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне - # failed-адреса не переотбираются бесконечно. + # failed-пары не переотбираются бесконечно. + # + # AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из + # is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…), + # а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская + # обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором, + # который пользователь никогда не увидит (is_active=false), 8 ночных прогонов + # подряд saved=0. Активные объявления с валидным адресом почти всегда попадают + # в topN только теперь, когда мусор не конкурирует за место в LIMIT. rows = ( db.execute( text( """ - SELECT address, COUNT(*) AS listings_count + SELECT address, city, COUNT(*) AS listings_count FROM listings WHERE lat IS NULL + AND is_active AND address IS NOT NULL AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR geocode_tried_at < NOW() - INTERVAL '7 days') - GROUP BY address - ORDER BY listings_count DESC, address ASC + GROUP BY address, city + ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST LIMIT :limit """ ), @@ -117,23 +156,43 @@ async def geocode_missing_listings( for idx, row in enumerate(rows): address: str = row["address"] + city: str | None = row.get("city") listings_count: int = row["listings_count"] result.addresses_processed += 1 try: - geo = await geocode(address, db) + # known_city_hint (#2603) — общий гейт по словарю городов области для + # всех DB-колоночных callers. Для listings.city он сегодня no-op + # (скрапер пишет только шесть кураторских имён из + # scraper_kit CITY_DISPLAY_NAMES, все они есть в словаре), но держит + # инвариант единым с deals-путями, где колонка росреестровая и в + # хвосте лежит мусор. Сырой `city` ниже остаётся ключом группы для + # UPDATE — гейт влияет только на подсказку геокодеру. + geo = await geocode(address, db, city_hint=known_city_hint(city)) except Exception as exc: logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc) result.addresses_failed += 1 if not dry_run: - # Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах - # этого же прогона (loop-safe backoff 7 дней). + # Пометить tried_at чтобы пара (address, city) не переотбиралась + # в следующих batch'ах этого же прогона (loop-safe backoff 7 дней). + # IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное + # `=` не поймает NULL-город и не должно задеть другой город с тем + # же текстом адреса. + # Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка + # для (address, city) КАК ТЕКСТА, а не для конкретного listing. + # is_active=false дубликат этой пары и так никогда не будет выбран + # SELECT'ом заново (is_active=false исключён там навсегда) — фильтр + # здесь был бы no-op для неактивных строк. Единственный случай когда + # это имеет значение — если строка позже реактивируется (is_active + # → true): тогда tried_at уже стоит и backoff корректно защищает от + # немедленного повторного запроса того же заведомо неудачного адреса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" - " WHERE address = :addr AND lat IS NULL" + " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" + " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) db.commit() continue @@ -141,18 +200,25 @@ async def geocode_missing_listings( if geo is None: result.addresses_failed += 1 logger.info( - "geocode_missing: NOT FOUND '%s' (used in %d listings)", + "geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)", address[:60], + city, listings_count, ) if not dry_run: # Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней. + # Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что + # и в except-ветке выше: backoff привязан к тексту (address, city), + # не к конкретному listing, is_active=false строка и так не выбирается + # SELECT'ом заново; при реактивации backoff корректно защитит от + # немедленного повтора заведомо неудачного запроса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" - " WHERE address = :addr AND lat IS NULL" + " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" + " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) db.commit() continue @@ -165,9 +231,13 @@ async def geocode_missing_listings( result.addresses_geocoded += 1 if dry_run: + # city в логе (#2603) — с #2594 это часть ключа группы: без него две + # строки dry-run с одинаковым текстом адреса неотличимы друг от друга. logger.info( - "geocode_missing[dry]: '%s' → (%.5f, %.5f) provider=%s would update %d listings", + "geocode_missing[dry]: '%s' city=%r → (%.5f, %.5f) provider=%s " + "would update %d listings", address[:60], + city, geo.lat, geo.lon, geo.provider, @@ -183,16 +253,37 @@ async def geocode_missing_listings( # UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически. # geo_precision и geocode_tried_at проставляются одновременно с координатами. + # city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из + # которой был geocode-запрос; иначе тот же текст адреса в другом городе + # (city IS NULL или другой явный город) перезаписался бы чужими координатами. + # + # Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического + # адреса, а не свойство конкретного объявления. Если у этой же пары + # (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же + # координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного + # листинга, доп. запроса не будет. SELECT выше и так навсегда исключает + # is_active=false строки из очереди — без этого UPDATE такой дубликат остался + # бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по + # координатам сломан для него), хотя ответ уже есть в руках. Единственный + # довод «за» фильтр — консистентность с SELECT — не перевешивает: это не + # ошибка данных (координаты адреса объективны и не зависят от активности), + # а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0). update_result = db.execute( text( """ UPDATE listings SET lat = :lat, lon = :lon, geo_precision = :precision, geocode_tried_at = NOW() - WHERE address = :addr AND lat IS NULL + WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL """ ), - {"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address}, + { + "lat": geo.lat, + "lon": geo.lon, + "precision": precision, + "addr": address, + "city": city, + }, ) db.commit() result.listings_updated += update_result.rowcount @@ -293,6 +384,13 @@ async def run_geocode_missing_listings( ) break if res.addresses_total < batch_size: + # #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была + # 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных + # пар address+city после GROUP BY) — этот дренаж почти всегда сработает + # уже на первой итерации (addresses_total < default batch_size=200), и + # это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего + # прогона. Никакого деления тут нет (только сравнение int), пустая + # очередь (addresses_total=0) ловится веткой выше, а не этой. logger.info( "run_geocode_missing_listings: run_id=%d — дренаж " "(addresses_total=%d < batch_size=%d), завершаем", diff --git a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py index 06a19ca5..81dcbf16 100644 --- a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py +++ b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py @@ -9,13 +9,34 @@ listing_source_events. Так история per-source цены копится через product_handlers._job_listing_source_snapshot, по образцу import_rosreestr_dkp (sync task в run_in_executor). -Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff CTE), -никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT … SELECT каждый. +Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff), никакого +row-by-row Python. + +#2607 — root cause висящих прогонов (ежедневный zombie с минимум 19 июля, всегда ровно 6h +до zombie-порога): event-diff раньше писал "prior" как CTE `DISTINCT ON (listing_source_id) +... ORDER BY listing_source_id, snapshot_date DESC` по ВСЕЙ listing_source_snapshots (~2.6-2.8M +строк) и джойнил её с "today" через обычный JOIN. Планировщик оценивает `snapshot_date = +CURRENT_DATE` в 1 строку (статистика ANALYZE ещё не видела свежевставленные в этой же +транзакции строки today — CURRENT_DATE всегда за пределами гистограммы), выбирает Nested +Loop БЕЗ Materialize на внутренней стороне и на КАЖДУЮ реальную строку today (~80-140k) +заново пересчитывает DISTINCT ON по всей таблице (Unique + Index Scan ~2.7M строк) — +EXPLAIN на проде показал cost≈300k именно на этом шаге. Реально это никогда не завершалось +за 6h, оставляя backend 'active' на сутки после того как zombie-детектор помечал +scrape_runs.status='zombie' (детектор НЕ убивает backend, см. reap_zombies) — держало +backend_xmin, блокируя autovacuum на listings/listing_sources. + +Fix: `prior` переписан через `JOIN LATERAL (... ORDER BY snapshot_date DESC LIMIT 1) ON true` +— форсирует per-row индексный point-lookup по idx_lss_source_date (listing_source_id, +snapshot_date DESC) вместо полного DISTINCT ON по таблице; EXPLAIN на проде: cost внутреннего +подзапроса упал с ~298 627 до ~4.4 за строку today. Плюс defense-in-depth: budget_sec → +SET LOCAL statement_timeout (см. snapshot_listing_sources) — если что-то опять разрегрессирует +план, прогон честно падает в mark_failed вместо того чтобы висеть сутками. """ from __future__ import annotations import logging +from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session @@ -27,6 +48,30 @@ logger = logging.getLogger(__name__) # Окно свежести: источник считается активным, если last_seen_at не старше N дней. FRESHNESS_WINDOW_DAYS = 7 +# ── Wall-clock budget (#2607 п.4) ───────────────────────────────────────────── +# Задача не батчится Python-циклом (два set-based statement'а) — единственный способ +# гарантированно оборвать зависший statement это Postgres-нативный statement_timeout, +# выставленный SET LOCAL (per-transaction scope, НЕ трогает server/role-level timeout — +# это issue #2607 п.2, отдельное решение с согласованием). По образцу budget_sec из +# app/tasks/geocode_missing.py (run_geocode_missing_listings), только здесь это не Python +# loop-budget, а SQL statement_timeout. +# Default/clamp: см. data/sql/202_listing_source_snapshot_budget_sec.sql (default_params +# budget_sec=900 — 15 мин, с большим запасом над ожидаемым временем выполнения после +# LATERAL-фикса (секунды) и далеко от 6h zombie-порога). +DEFAULT_BUDGET_SEC = 900.0 +_MIN_BUDGET_SEC = 30.0 +_MAX_BUDGET_SEC = 3600.0 # hard ceiling — не даём budget_sec случайно воссоздать "висит вечно" + + +def _clamp_budget_sec(raw: Any) -> float: + """Валидировать/зажать budget_sec из default_params — защита от 0/отрицательного/мусора.""" + try: + val = float(raw) + except (TypeError, ValueError): + val = DEFAULT_BUDGET_SEC + return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC)) + + # ── Daily snapshot upsert ───────────────────────────────────────────────────── # Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день # (повторный прогон в те же сутки перезаписывает снимок свежими значениями). @@ -59,83 +104,209 @@ _SNAPSHOT_SQL = text( """ ) -# ── Event diff: price_change ────────────────────────────────────────────────── -# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с -# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась -# (обе NOT NULL, old <> 0) — пишем price_change. -# today — снимок за сегодня (только что записан _SNAPSHOT_SQL). -# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC). -# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла. -# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type) -# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным. +# ── Event diff: три выводимых типа событий из пяти в схеме ──────────────────── +# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с +# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE). +# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции). +# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607). +# +# #2674: схема (079) знает пять типов событий, писатель умел один — price_change, +# 8288 строк. Дописаны два: +# edited — payload_hash изменился, а цена нет (изменение цены уже описано +# отдельным событием price_change — дублировать его как «редактирование» +# значило бы считать одно изменение дважды). Прошлый хеш обязан быть +# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не +# правка, а первое наблюдение; +# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL). +# +# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных. +# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS», +# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35% +# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что +# объявление вернулось/ушло. Контрольная группа в наших же данных (14-18.07): +# domklik, покрытие 99.9-100%: снятий 1/2/0/2/4 в сутки, возвратов — РОВНО 0 все дни; +# yandex, покрытие 34-43%: снятий 343-433 в сутки, возвратов до 155. +# Тот же обход, тот же день — разница только в покрытии. Отсюда же всплески: +# avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной +# ставки 1-4, точность события ≈4%; 4705 «возвратов» из 5493 за 12 дней (86%) — это +# два дня после возобновления обхода 2-3.08. +# Сузить окно свежести НЕ поможет — станет хуже (больше флапаний); окно шире +# максимального интервала повторного визита обессмысливает само событие. +# Честный ответ схеме — не писать эти два типа, а не наполнять журнал догадками. +# Единственный жёсткий сигнал снятия — 404 при поштучном обходе, он пишется в +# listings_snapshots.status='closed' (avito_detail_backfill). +# +# Оставшиеся три события утверждают факты о НАШИХ СОБСТВЕННЫХ строках («появился новый +# источник», «хеш изменился при той же цене», «цена другая»), а не о поведении площадки. +# +# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались +# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется — +# LEFT JOIN LATERAL так же форсирует per-row индексный point-lookup по +# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей. +# +# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт +# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это +# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката +# вместо одного. +# +# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM +# listing_source_snapshots WHERE snapshot_date < CURRENT_DATE` и джойнился обычным JOIN. +# Планировщик оценивает `today` в 1 строку (свежевставленные в этой же транзакции строки +# ANALYZE ещё не видел) → Nested Loop БЕЗ Materialize на внутренней стороне → DISTINCT ON +# по ВСЕЙ таблице (~2.6-2.8M строк, Index Scan + Unique) пересчитывался ЗАНОВО на каждую +# из ~80-140k реальных строк today — на проде EXPLAIN показал cost≈300k на этом шаге, +# запрос не укладывался ни в 6h zombie-порог, ни в сутки. LATERAL форсирует per-row +# индексный lookup через idx_lss_source_date (listing_source_id, snapshot_date DESC) — +# `ORDER BY s.snapshot_date DESC LIMIT 1` даёт тот же единственный "последний снимок до +# сегодня" на listing_source_id, что и старый DISTINCT ON (PK (listing_source_id, +# snapshot_date) исключает дубликаты snapshot_date на одном источнике — семантика +# идентична), но за O(log n) на строку вместо полного скана таблицы. EXPLAIN на проде: +# cost внутреннего подзапроса упал с ~298 627 до ~4.4 за строку today. +# +# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL +# — это внутренний план Postgres, не Python-итерация). +# +# change_time = date_trunc('day', now()), а НЕ now() (#2674): с now() уникальность +# UNIQUE(listing_source_id, change_time, event_type) работала только ВНУТРИ прогона — +# второй прогон в те же сутки перезаписывал сегодняшний снимок, предикаты срабатывали +# заново с другим временем и давали дубли (2 августа таких прогонов было два). +# Суточная гранулярность честнее для суточного же сравнения и включает заявленную +# идемпотентность: ON CONFLICT DO NOTHING теперь действительно гасит повтор за день. +# +# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО +# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже +# не спасает — без NULLIF первый же источник с нулевой прошлой ценой уронил бы весь +# прогон. Результат при этом тот же: строка с NULL-диффом не проходит e.fires. +# +# Внешний SELECT над data-modifying CTE считает вставленное ПО ТИПАМ (RETURNING отдаёт +# только реально вставленные строки, не съеденные ON CONFLICT), сразу в виде ключей +# счётчиков `_events` — писатель получает готовый dict без Python-агрегации. +# Ровно этот счётчик и показал бы четыре нуля из пяти, если бы существовал раньше. _EVENT_DIFF_SQL = text( """ WITH today AS ( - SELECT listing_source_id, price_rub + SELECT listing_source_id, price_rub, payload_hash FROM listing_source_snapshots WHERE snapshot_date = CURRENT_DATE ), - prior AS ( - SELECT DISTINCT ON (listing_source_id) - listing_source_id, price_rub - FROM listing_source_snapshots - WHERE snapshot_date < CURRENT_DATE - ORDER BY listing_source_id, snapshot_date DESC + inserted AS ( + INSERT INTO listing_source_events ( + listing_source_id, change_time, event_type, price_rub, diff_percent + ) + SELECT + t.listing_source_id, + date_trunc('day', now()), + e.event_type, + t.price_rub, + e.diff_percent + FROM today t + LEFT JOIN LATERAL ( + SELECT s.snapshot_date, s.price_rub, s.payload_hash + FROM listing_source_snapshots s + WHERE s.listing_source_id = t.listing_source_id + AND s.snapshot_date < CURRENT_DATE + ORDER BY s.snapshot_date DESC + LIMIT 1 + ) p ON true + CROSS JOIN LATERAL (VALUES + ( + 'first_seen', + NULL::numeric, + p.snapshot_date IS NULL + ), + ( + 'price_change', + round((t.price_rub - p.price_rub)::numeric + / NULLIF(p.price_rub, 0) * 100, 4), + t.price_rub IS NOT NULL + AND p.price_rub IS NOT NULL + AND p.price_rub <> 0 + AND t.price_rub <> p.price_rub + ), + ( + 'edited', + NULL::numeric, + p.payload_hash IS NOT NULL + AND t.payload_hash IS DISTINCT FROM p.payload_hash + AND t.price_rub IS NOT DISTINCT FROM p.price_rub + ) + ) AS e(event_type, diff_percent, fires) + WHERE e.fires + ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING + RETURNING event_type ) - INSERT INTO listing_source_events ( - listing_source_id, change_time, event_type, price_rub, diff_percent - ) - SELECT - t.listing_source_id, - now(), - 'price_change', - t.price_rub, - round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4) - FROM today t - JOIN prior p ON p.listing_source_id = t.listing_source_id - WHERE t.price_rub IS NOT NULL - AND p.price_rub IS NOT NULL - AND p.price_rub <> 0 - AND t.price_rub <> p.price_rub - ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING + SELECT event_type || '_events' AS counter_key, count(*) AS n + FROM inserted + GROUP BY 1 """ ) -def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]: - """Записать дневной снимок listing_sources + price_change-события. +def snapshot_listing_sources( + db: Session, run_id: int, params: dict[str, Any] | None = None +) -> dict[str, int]: + """Записать дневной снимок listing_sources + события изменений. Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Два set-based statement'а в одной транзакции: 1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins. - 2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события. + 2. diff сегодняшнего снимка против последнего предыдущего → три события, + выводимые из наших данных (#2674). delisted/relisted схема разрешает, но + они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL. + + Params (из default_params jsonb в scrape_schedules, #2607): + budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900, + clamp [30, 3600]). Единственный способ гарантированно оборвать зависший + statement у не-батчащейся (два statement'а, не Python-цикл) задачи — если + план снова разрегрессирует, прогон честно упадёт в mark_failed вместо того + чтобы висеть часами/сутками (root cause #2607 — см. шапку файла и + _EVENT_DIFF_SQL). Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. - Returns {"snapshotted": N, "price_change_events": M}. + Returns {"snapshotted": N, "_events": M} — по счётчику на каждый из + трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал + ни разу, честно показывает 0, а не пропадает из counters). """ - counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0} + params = params or {} + budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC)) + counters: dict[str, int] = { + "snapshotted": 0, + "price_change_events": 0, + "edited_events": 0, + "first_seen_events": 0, + } try: + # statement_timeout НЕ принимает bind-параметр ($1/:name) — синтаксис Postgres SET + # запрещает placeholder на этом месте (проверено вживую на проде: "syntax error at + # or near \"$1\""). budget_sec провалидирован/clamp'нут в _clamp_budget_sec выше + # (источник — scrape_schedules.default_params, не user input) — f-string здесь + # безопасен (единственный практический способ выставить эту GUC динамически). + # SET LOCAL — per-transaction scope, сбрасывается на COMMIT/ROLLBACK, НЕ трогает + # server/role-level statement_timeout (issue #2607 п.2 — отдельное решение). + timeout_ms = int(budget_sec * 1000) + db.execute(text(f"SET LOCAL statement_timeout = {timeout_ms}")) + snap_result = db.execute( _SNAPSHOT_SQL, {"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id}, ) counters["snapshotted"] = snap_result.rowcount or 0 - event_result = db.execute(_EVENT_DIFF_SQL) - counters["price_change_events"] = event_result.rowcount or 0 + # Statement возвращает уже готовые пары (counter_key, n) по типам событий — + # dict(...) без Python-агрегации, набор ключей задан инициализацией counters + # выше, так что не сработавшие типы остаются нулями, а не исчезают. + event_rows = db.execute(_EVENT_DIFF_SQL).fetchall() + counters.update(dict(event_rows)) db.commit() runs_mod.mark_done(db, run_id, counters) - logger.info( - "snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d", - run_id, - counters["snapshotted"], - counters["price_change_events"], - ) + logger.info("snapshot_listing_sources run_id=%d done: %s", run_id, counters) return counters except Exception as exc: - logger.exception("snapshot_listing_sources run_id=%d failed", run_id) + logger.exception( + "snapshot_listing_sources run_id=%d failed (budget_sec=%.0f)", run_id, budget_sec + ) db.rollback() runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index b6f5dda1..e46fe9d9 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -66,6 +66,7 @@ import json import logging import random import time +from collections.abc import Callable from dataclasses import dataclass, field, fields from sqlalchemy import text @@ -306,8 +307,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int] rc = int( db.execute( text( - "SELECT COUNT(*) FROM house_reliability_checks " - "WHERE house_id = CAST(:h AS bigint)" + "SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)" ), {"h": house_id}, ).scalar_one() @@ -328,6 +328,7 @@ async def backfill_newbuilding_enrichment( force: bool = False, request_delay_sec: float | None = None, dry_run: bool = False, + on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None, ) -> NewbuildingEnrichBackfillResult: """Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses. @@ -347,6 +348,11 @@ async def backfill_newbuilding_enrichment( (default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing a resolve incurs TWO delays (resolve fetch + enrich fetch). dry_run: count the population + log the pending list, fetch nothing, write nothing. + on_progress: колбэк живости (#2725) — вызывается на каждом доме с текущим + (мутируемым) result; caller пишет scrape_runs.heartbeat_at. Без него + heartbeat уходил один раз до цикла, а `reap_zombies` меряет именно его: + дом обходится за ~2.6 мин, и на limit'е порядка 140 (полный прогон — 318 + домов, см. выше) прогон переваливал бы 6-часовой порог живым. Returns: NewbuildingEnrichBackfillResult with population sizing, per-house outcome @@ -415,6 +421,8 @@ async def backfill_newbuilding_enrichment( zhk_url: str | None = row["cian_zhk_url"] ext_id: str | None = row["ext_id"] result.processed += 1 + if on_progress is not None: + on_progress(result) # Idempotency fast-path: with force=False the SELECT already excludes enriched # houses (price_dynamics + reliability present), so this branch is a belt-and- @@ -696,6 +704,18 @@ async def run_newbuilding_enrich( "failed_save": 0, } + def _heartbeat(progress: NewbuildingEnrichBackfillResult) -> None: + """Сигнал живости из середины цикла (#2725). Best-effort — сбой heartbeat не + должен ронять уже идущий обход.""" + try: + runs_mod.update_heartbeat(db, run_id, progress.to_dict()) + except Exception: + logger.warning( + "scheduler: newbuilding_enrich run_id=%d heartbeat failed (ignored)", + run_id, + exc_info=True, + ) + try: runs_mod.update_heartbeat(db, run_id, counters) @@ -704,6 +724,7 @@ async def run_newbuilding_enrich( limit=limit, force=force, request_delay_sec=request_delay_sec, + on_progress=_heartbeat, ) counters = result.to_dict() diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 00e49fdc..487179d5 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -9,26 +9,47 @@ видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага +поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING +событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его +застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) +писал ERROR с самого начала — расходилась только эта джоба. + +ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов +монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего +собственного такта загрузки: + 13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65 + 17.07 alert=0 age 46 latest=июнь (день загрузки) +Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же +считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок +46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать +такое до ERROR без починки такта значило бы завести ежедневное ложное событие на +две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ +такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает +«источник/загрузка встали», а не «мы давно не ходили». + Порог алерта (документирование выбора): Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). Монитор: age > sber_index_max_age_days + lag_allowance. lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг - публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл - ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог: - 35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет - (иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев - → алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1. + публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл + ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток. + Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста + ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл + поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри + рабочего диапазона — если такт загрузки будут менять, пересчитай потолок + (пол + interval_days) и сверь с 60. Задача синхронная (DB-only, один SELECT max(period_month)) — запускается kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now тестируется без БД). -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING -достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи +достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего оценивать). """ @@ -136,7 +157,10 @@ def check_sber_freshness( row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() latest: date | None = row.latest if row is not None else None if latest is None: - logger.warning( + # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, + # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), + # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. + logger.error( "sber freshness: sber_price_index пуст/недоступен для region=%s " "(вторичка) — оценить свежесть нельзя", SBER_MONITOR_CITY, @@ -156,7 +180,9 @@ def check_sber_freshness( } if verdict.stale: - logger.warning( + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — + # 9 срабатываний на проде дали ноль событий. См. докстринг модуля. + logger.error( "sber freshness: max(period_month)=%s устарел на %d дней " "(> порога %d = sber_index_max_age_days %d + lag %d); " "СберИндекс time-adjustment ДКП-сделок мог отстать — " diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index b6b6f865..16873d03 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -12,8 +12,10 @@ offer detail page via curl_cffi AsyncSession (chrome120 + proxy) — mirrors yandex_address_backfill.py which already gets full HTML from Yandex on prod. Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via save_detail_enrichment. Track consecutive parse→None results; abort after -max_consecutive_blocks (mark_done, not mark_failed — retry next night via -NULL detail_enriched_at). +max_consecutive_blocks. Прогон с нулём обогащений теперь 'failed', не 'done' +(#2674, runs.mark_backfill_finished): на проде 31 прогон из 52 упирался ровно в +этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток +снапшота уедет в следующую ночь через NULL detail_enriched_at. Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS @@ -85,7 +87,8 @@ async def run_yandex_detail_backfill( (possible captcha wall); consecutive None → abort after max_consecutive_blocks. Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> - mark_done (incl. partial / consecutive-None abort) / mark_failed (exception only). + mark_backfill_finished (done / failed при нуле обогащений, #2674); + mark_failed напрямую — только при исключении. """ batch_size = int(params.get("batch_size", 800)) budget_sec = float(params.get("budget_sec", 3600)) @@ -278,9 +281,11 @@ async def run_yandex_detail_backfill( counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() - runs_mod.mark_done(db, run_id, current_counters) + runs_mod.mark_backfill_finished( + db, run_id, current_counters, source="yandex_detail_backfill" + ) logger.info( - "yandex_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " + "yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "failed=%d duration=%.1fs", run_id, counters.attempted, diff --git a/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql b/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql index 1c50e88d..57896437 100644 --- a/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql +++ b/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql @@ -12,7 +12,14 @@ -- -- СОЗДАЁТ: -- asking_to_sold_ratios — таблица коэффициентов (rooms_bucket, district) → ratio. --- rooms_bucket: 0=студия,1,2,3,4(=4+); СПЕЦ-строка rooms_bucket=-1 = global fallback. +-- rooms_bucket: ИМЯ ЛЕГАСИ — с #2620 (2026-08) семантика AREA-BASED, не «комнаты»: +-- 0=area<30, 1=area<44, 2=area<62, 3=area<85, 4=area>=85 м² (границы = ровно та же +-- формула, что синтезирует deals.rooms из площади при импорте, см. deploy/import- +-- rosreestr.sh и app/tasks/asking_to_sold_ratio.py: _AREA_ROOMS_BUCKET_SQL/area_bucket()). +-- Причина: Росреестр не отдаёт реальную комнатность, поэтому обе стороны (расчёт ask_side +-- И применение в estimator.py) ключуются по площади — сравнение «area-бакет vs реальные +-- комнаты» давало систематический mismatch (до 55% строк не в своём бакете, #2620). +-- СПЕЦ-строка rooms_bucket=-1 = global fallback. -- district: ЗАРЕЗЕРВИРОВАНО для #647 (geo-разбивка); в #648 ВСЕГДА '' (часть PK, -- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя). -- @@ -61,7 +68,8 @@ BEGIN; -- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''. -- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем). CREATE TABLE IF NOT EXISTS asking_to_sold_ratios ( - rooms_bucket int NOT NULL, -- 0=студия..4=4+; -1 = global fallback row + rooms_bucket int NOT NULL, -- legacy name, area-based since #2620: + -- 0=area<30..4=area>=85; -1=global fallback district text NOT NULL DEFAULT '', -- RESERVED for #647 (always '' in #648) ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2 sold_median bigint, -- median(deals.price_per_m2), диагностика @@ -75,18 +83,25 @@ CREATE TABLE IF NOT EXISTS asking_to_sold_ratios ( ); COMMENT ON TABLE asking_to_sold_ratios IS - 'Per-rooms asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). ' - 'rooms_bucket 0=студия..4=4+; -1 = global fallback (basis=global_fallback, пишется всегда). ' + 'Asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). ' + 'rooms_bucket — LEGACY NAME, area-based since #2620: 0=area<30..4=area>=85 m2 (same ' + 'formula deals.rooms is synthesized from, see import-rosreestr.sh); -1 = global fallback ' + '(basis=global_fallback, пишется всегда). ' 'Per-rooms строки только при n_deals>=30 AND n_listings>=30 (иначе estimator читает -1). ' 'district зарезервирован под #647 (geo), в #648 всегда ''''. ' 'Caveat: ask=ТЕКУЩИЕ listings vs sold=сделки за 12 мес (не point-in-time); ДКП=registered. ' - 'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже.'; + 'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже (area-bucket ' + 'ask-side since #2620 — see app/tasks/asking_to_sold_ratio.py, this seed predates it).'; -- ── Derivation + seed ───────────────────────────────────────────────────────── -- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы -- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок -- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет -- LEAST(GREATEST(rooms,0),4) — байт-в-байт как в харнесе (PPM2_MIN/PPM2_MAX, _bucketize_rooms). +-- #2620 (2026-08): live-рефреш (app/tasks/asking_to_sold_ratio.py) ушёл от этого fresh-install +-- seed — ask_side там бакетируется по площади (_AREA_ROOMS_BUCKET_SQL), а не rooms; см. комментарий +-- в СОЗДАЁТ выше и модуль asking_to_sold_ratio.py. Этот CTE-блок оставлен как есть (fresh-install +-- seed, применяется один раз через _schema_migrations) — не источник истины для прод-derivation. WITH -- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). deal_side AS ( diff --git a/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql b/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql index c4492400..4be5641c 100644 --- a/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql +++ b/tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql @@ -13,7 +13,15 @@ -- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО -- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не -- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241 --- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр). +-- кандидатов → 1008 проставлено. +-- +-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов +-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились +-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1" +-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга. +-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же +-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано +-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты. -- -- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну -- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true. diff --git a/tradein-mvp/backend/data/sql/192_tradein_users_auth.sql b/tradein-mvp/backend/data/sql/192_tradein_users_auth.sql new file mode 100644 index 00000000..df5193c8 --- /dev/null +++ b/tradein-mvp/backend/data/sql/192_tradein_users_auth.sql @@ -0,0 +1,88 @@ +-- Migration 192: tradein_users + tradein_sessions — DB-backed auth (issue #2551, эпик #2549) +-- +-- WHY: +-- Trade-in auth сейчас держится на legacy Caddy basic-auth fallback (см. auth/roles.yaml, +-- упомянут в 191_account_quota_unlimited_flag.sql как "хардкод username в коде"). Эпик #2549 +-- переводит auth на DB-backed модель: пользователи + сессии как данные, роли admin/manager/ +-- employee с иерархией manager -> employee. Эта миграция — только схема (Foundation), +-- без seed-данных (seed — отдельная задача #2557) и без Python-кода (backend wiring — отдельно). +-- +-- WHAT: +-- 1. tradein_users — identity + role + org-иерархия. +-- - password_hash NULL допустим: переходный период, когда логин ещё идёт через +-- legacy Caddy fallback, а не через password verify в приложении. +-- - role CHECK ('admin','manager','employee') — три уровня доступа. +-- - manager_id — self-FK, ON DELETE SET NULL (увольнение/удаление manager'а не должно +-- каскадно сносить его employees, они просто остаются без привязки). +-- - CHECK role_manager_hierarchy: admin/manager обязаны иметь manager_id IS NULL +-- (это top-level роли, у них нет "начальника" в этой модели); employee — manager_id +-- любой, включая NULL (свободный слот employee без организации допустим). +-- 2. tradein_sessions — токен-based сессии, привязаны к user_id, ON DELETE CASCADE +-- (удалили пользователя — его сессии теряют смысл, каскадная очистка корректна). +-- last_seen_at отдельно от created_at — для idle-timeout / активности сессии. +-- 3. Индексы: expires_at (уборка протухших сессий), user_id (список сессий юзера), +-- partial на manager_id (иерархия) — WHERE manager_id IS NOT NULL, т.к. большинство +-- admin/manager строк это NULL и не участвуют в lookup "employees этого manager'а". +-- +-- IDEMPOTENCY: +-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Повторный прогон — no-op. +-- CHECK-констрейнты добавлены inline в CREATE TABLE (не через ALTER) — при повторном +-- запуске CREATE TABLE IF NOT EXISTS не выполнится вообще, констрейнт не задублируется. +-- +-- Dependencies: нет (новые таблицы, ничего существующего не меняем). +-- Deploy order: эта миграция — Foundation эпика #2549. Seed (#2557) и backend auth-код — +-- отдельные PR'ы ПОСЛЕ этой (SQL-схема первой, см. .claude/rules/sql.md "Migration order"). + +BEGIN; + +CREATE TABLE IF NOT EXISTS tradein_users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username text NOT NULL UNIQUE, + password_hash text NULL, + role text NOT NULL CHECK (role IN ('admin', 'manager', 'employee')), + manager_id bigint NULL REFERENCES tradein_users(id) ON DELETE SET NULL, + display_name text NULL, + org_name text NULL, + email text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT tradein_users_role_manager_hierarchy_ck CHECK ( + role NOT IN ('admin', 'manager') OR manager_id IS NULL + ) +); + +COMMENT ON TABLE tradein_users IS + 'Trade-in DB-backed auth — пользователи (issue #2551, эпик #2549). password_hash NULL ' + 'допустим в переходный период (логин через legacy Caddy fallback). Seed — отдельно (#2557).'; +COMMENT ON COLUMN tradein_users.password_hash IS + 'NULL = логин только через legacy Caddy basic-auth fallback, не через password verify.'; +COMMENT ON COLUMN tradein_users.manager_id IS + 'Self-FK на tradein_users(id). NULL для admin/manager (top-level, CHECK ниже) или для ' + 'employee без назначенной организации.'; + +CREATE TABLE IF NOT EXISTS tradein_sessions ( + token text PRIMARY KEY, + user_id bigint NOT NULL REFERENCES tradein_users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL DEFAULT now(), + ip_address inet NULL, + user_agent text NULL +); + +COMMENT ON TABLE tradein_sessions IS + 'Trade-in DB-backed auth — активные сессии (issue #2551, эпик #2549). ' + 'ON DELETE CASCADE от tradein_users: удалённый пользователь теряет все сессии.'; + +CREATE INDEX IF NOT EXISTS tradein_sessions_expires_at_idx + ON tradein_sessions (expires_at); + +CREATE INDEX IF NOT EXISTS tradein_sessions_user_id_idx + ON tradein_sessions (user_id); + +CREATE INDEX IF NOT EXISTS tradein_users_manager_id_idx + ON tradein_users (manager_id) + WHERE manager_id IS NOT NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql new file mode 100644 index 00000000..7eea9d95 --- /dev/null +++ b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql @@ -0,0 +1,127 @@ +-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username +-- (issue #2557, эпик #2549) +-- +-- WHY: +-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных — +-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только +-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены +-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта, +-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556, +-- team-UI для проставления паролей — отдельная задача, тоже #2556). +-- +-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных): +-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические +-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой +-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую +-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая +-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже +-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом +-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть +-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то +-- не забудет проверить в UI/API layer. +-- +-- WHAT: +-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$'). +-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint, +-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте +-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен). +-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30): +-- admin role=admin, manager_id=NULL, is_active=true (владелец) +-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент) +-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика») +-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org) +-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ +-- закрыт 2026-07-30) +-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556). +-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ: +-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode, +-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ +-- из-за этой миграции. +-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже +-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем. +-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры +-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны. +-- +-- IDEMPOTENCY: +-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает +-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из +-- 189_account_estimate_usage_nonnegative.sql. +-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, +-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) — +-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет +-- manager_id при создании сотрудника менеджером) / поменял display_name вручную +-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply +-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не +-- запускает файл дважды на одном окружении, но scratch/staging БД такого +-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом / +-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный +-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из +-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде. +-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины +-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой +-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не +-- заморозить первый прогон навсегда. +-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT +-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в +-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium). +-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается +-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки +-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше +-- не трогает. +-- +-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions). +-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и +-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см. +-- .claude/rules/sql.md "Migration order"). + +BEGIN; + +-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tradein_users_username_ascii_ck' + ) THEN + ALTER TABLE tradein_users + ADD CONSTRAINT tradein_users_username_ascii_ck + CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$'); + END IF; +END $$; + +COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS + 'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): ' + 'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII ' + 'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).'; + +-- Часть 2: seed org-карты (владелец продукта, 2026-07-30). +INSERT INTO tradein_users + (username, password_hash, role, manager_id, display_name, org_name, email, is_active) +VALUES + ('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true), + ('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true), + ('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true), + ('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false), + ('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true) +ON CONFLICT (username) DO UPDATE SET + role = EXCLUDED.role, + -- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении + -- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь. + manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id), + password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash), + display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name), + org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name), + email = COALESCE(tradein_users.email, EXCLUDED.email), + -- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом + -- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом. + updated_at = now(); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql b/tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql new file mode 100644 index 00000000..2a6e51ad --- /dev/null +++ b/tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql @@ -0,0 +1,150 @@ +-- 194_deal_city_price_bands_tiers.sql +-- Эпик #2576 Stage B — многоуровневые ценовые бэнды по городам + честный +-- региональный фолбэк вместо ЕКБ-калиброванного порога. +-- +-- ПРОБЛЕМА: +-- Миграция 178 построила deal_city_price_bands РАЗОВО, только для городов +-- с count(*) >= 30 сделок (HAVING count(*) >= 30) на момент прогона. Auto-refresh +-- не был реализован (см. комментарий в 178). Город без строки в таблице +-- попадает на глобальный DEAL_MIN_PPM2=50_000 (estimator.py) — порог, +-- откалиброванный ИСКЛЮЧИТЕЛЬНО по Екатеринбургу. Для малых городов области +-- это не anti-outlier guard, а cut-off легитимного рынка (Североуральск +-- median ≈ 21.7k ₽/м²). +-- +-- Замер по прод-данным deals (2026-07-31, source='rosreestr', city IS NOT NULL, +-- city <> 'Екатеринбург', price_per_m2 IS NOT NULL — 47 253 сделки / 369 городов): +-- N>=30 сделок → 80 городов (45 988 сделок, 97.3%) — уже покрыты 178. +-- N 15-29 → 21 город ( 460 сделок) — падали на global-50k fallback. +-- N 10-14 → 21 город ( 247 сделок) — падали на global-50k fallback. +-- N 1-9 → 247 городов ( 558 сделок) — падали на global-50k fallback, +-- per-city перцентиль на такой выборке статистически бессмысленен +-- (n=1 → «перцентиль» = единственная сделка). +-- Итого 289 городов / 1265 сделок (2.7% выборки, но 78% ДОЛГОГО ХВОСТА городов) +-- получали ЕКБ-калиброванный пол вместо своей реальной цены. +-- +-- РЕШЕНИЕ — трёхуровневая схема (колонка tier), вместо единого порога 30: +-- 'full' N>=30 — own p1/p99 перцентиль (BYTE-IDENTICAL 178-derivation, +-- ЕКБ и существующие 80 городов НЕ меняются). +-- 'rough' 10<=N<30 — own p1 (floor), ceiling ФИКСИРОВАН на 800000 +-- (не деривится из тонкой выборки — p99 на <30 точках +-- нестабилен, одна дорогая сделка исказит потолок). +-- 'region_fallback' 1<=N<10 — own-данные города СЛИШКОМ тонкие даже для floor +-- (единичная сделка = 100% перцентиля недостоверна). +-- Используем ПУЛ по всей области (region_stats CTE, +-- p1 по 47k+ не-ЕКБ сделкам = 15 263 ₽/м² на момент +-- замера) вместо DEAL_MIN_PPM2=50000 (ЕКБ-калибровка). +-- Честнее: 15k отражает реальный низ рынка обл.66, +-- а не искусственно завышенный екб-порог. +-- +-- Екатеринбург по-прежнему НЕ включён (estimator.py fallback на глобальные +-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 остаётся единственным путём для ЕКБ — invariant +-- из 178 сохранён). После этой миграции ЕВСЕ 369 не-ЕКБ городов, встречающихся +-- в deals, получают строку — Python-fallback в estimator.py (COALESCE(b.ppm2_min, +-- :ppm_min)) отныне срабатывает практически только для ЕКБ (плюс узкое окно +-- между refresh-циклами для только что появившегося города). +-- +-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS + DO-блок guard на CHECK constraint +-- (PG 16 не поддерживает ADD CONSTRAINT IF NOT EXISTS). INSERT ... ON CONFLICT +-- DO UPDATE — повторный прогон рефрешит бэнды под свежие сделки (та же +-- семантика, что и 178). Без DELETE — множество городов монотонно растёт +-- (rosreestr_dkp_import только INSERT/UPDATE, никогда не удаляет), поэтому +-- merge-по-ключу достаточен (см. app/tasks/deal_city_price_bands_refresh.py — +-- периодический refresh, та же derivation байт-в-байт). +-- +-- Dependencies: 177_deals_city_region.sql (deals.city), 178_deal_city_price_bands.sql +-- (таблица + PK(city)). +-- Apply after: -- +-- Deploy order: эта миграция ПЕРЕД деплоем backend-кода, который регистрирует +-- scheduler-source 'deal_city_price_bands_refresh' (product_handlers.py) — +-- см. 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql (deploy after +-- backend-код задеплоен, тот же порядок, что 088). + +BEGIN; + +ALTER TABLE deal_city_price_bands + ADD COLUMN IF NOT EXISTS tier text NOT NULL DEFAULT 'full'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'deal_city_price_bands_tier_check' + ) THEN + ALTER TABLE deal_city_price_bands + ADD CONSTRAINT deal_city_price_bands_tier_check + CHECK (tier IN ('full', 'rough', 'region_fallback')); + END IF; +END $$; + +COMMENT ON COLUMN deal_city_price_bands.tier IS + 'full: N>=30 сделок, own p1/p99 band (миграция 178, unchanged). ' + 'rough: 10<=N<30, own p1 floor + фиксированный 800000 ceiling (миграция 194). ' + 'region_fallback: 1<=N<10, pooled Свердловская-обл. p1 floor (region_stats, ' + 'все не-ЕКБ сделки) + фиксированный 800000 ceiling — вместо ' + 'ЕКБ-калиброванного DEAL_MIN_PPM2=50000 (estimator.py).'; + +WITH region_stats AS ( + -- Пул по ВСЕЙ области (не-ЕКБ) — честный фолбэк для городов, где own-выборка + -- (N<10) слишком тонкая для собственного перцентиля. + SELECT GREATEST( + round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, + 8000 + ) AS region_ppm2_min + FROM deals + WHERE source = 'rosreestr' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND city <> 'Екатеринбург' +), +city_stats AS ( + SELECT + city, + GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000) + AS ppm2_p1, + LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000) + AS ppm2_p99, + count(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND city <> 'Екатеринбург' + GROUP BY city +), +tiered AS ( + -- full — байт-в-байт исходная 178-derivation (own p1/p99), плюс тот же + -- анти-мусорный инвариант (p99 < 8000 → город не матчил бы ни одну сделку). + SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals, + 'full'::text AS tier + FROM city_stats + WHERE n_deals >= 30 + AND ppm2_p99 >= 8000 + + UNION ALL + + -- rough — собственный p1 (floor), ceiling НЕ деривится (тонкая выборка). + SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals, + 'rough'::text AS tier + FROM city_stats + WHERE n_deals BETWEEN 10 AND 29 + + UNION ALL + + -- region_fallback — собственных данных недостаточно даже для floor, берём + -- пул по области целиком. + SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals, + 'region_fallback'::text AS tier + FROM city_stats c + CROSS JOIN region_stats r + WHERE c.n_deals < 10 +) +INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at) +SELECT city, ppm2_min, ppm2_max, n_deals, tier, now() +FROM tiered +ON CONFLICT (city) DO UPDATE + SET ppm2_min = EXCLUDED.ppm2_min, + ppm2_max = EXCLUDED.ppm2_max, + n_deals = EXCLUDED.n_deals, + tier = EXCLUDED.tier, + refreshed_at = EXCLUDED.refreshed_at; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql b/tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql new file mode 100644 index 00000000..a043fb34 --- /dev/null +++ b/tradein-mvp/backend/data/sql/195_scrape_schedules_seed_deal_city_price_bands_refresh.sql @@ -0,0 +1,62 @@ +-- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql +-- Эпик #2576 Stage B — seed scrape_schedules row для daily-рефреша +-- deal_city_price_bands (миграция 194). +-- +-- ПРОБЛЕМА: 178/194 заполняют deal_city_price_bands на момент прогона миграции. +-- По мере ночного импорта новых ДКП-сделок (rosreestr_dkp_import, 04:00-06:00 UTC) +-- бэнды (own p1/p99, tier-границы N) устаревают — города переходят между tier +-- ('region_fallback' → 'rough' → 'full') по мере накопления сделок, а сами +-- перцентили внутри tier дрейфуют. Auto-refresh отсутствовал (см. follow-up +-- в 178) — эта миграция закрывает разрыв. +-- +-- Задача (app/tasks/deal_city_price_bands_refresh.py, byte-identical derivation +-- 194) — pure-internal DB re-derivation, никаких внешних HTTP-вызовов. Запускается +-- kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh +-- (run_in_executor, по образцу _job_asking_to_sold_ratio). +-- +-- enabled = true — БЕЗОПАСНО включать сразу (тот же аргумент, что 082/088: pure DB, +-- без анти-бота). +-- Окно 07:00-08:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00, см. 072) И +-- asking_to_sold_ratio_refresh (06:00-07:00, см. 082), чтобы бэнды считались по +-- тому же свежему срезу deals, что и ratio-таблица того же дня. +-- next_run_at = завтрашнее наступление окна (tomorrow + 07:00 UTC) — тот же паттерн, +-- что 078/079/082/088 (иначе get_due_schedules() выстрелит сразу после деплоя). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), +-- 194_deal_city_price_bands_tiers.sql (tier-колонка, которую переиспользует refresh). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно. +-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего +-- 'deal_city_price_bands_refresh' в product_handlers.build_product_handlers() +-- (тот же порядок, что 088 relative к scheduler.py) — иначе kit-scheduler не +-- найдёт Handler для нового source и упадёт в "unknown source" на первом due-run +-- (не раньше завтрашнего окна — не блокирует деплой). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'deal_city_price_bands_refresh', + true, -- SAFE: pure internal DB, no external calls + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). ' + 'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), ' + 'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), ' + 'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), ' + 'deal_city_price_bands_refresh (#2576 Stage B).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/196_listings_city.sql b/tradein-mvp/backend/data/sql/196_listings_city.sql new file mode 100644 index 00000000..2da941a6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/196_listings_city.sql @@ -0,0 +1,44 @@ +-- 196_listings_city.sql +-- Issue #2594 — критичный дефект: скрапер знает город в момент сбора (city_slug из +-- CITY_LOCATIONS/CITY_ANCHORS, packages/scraper-kit/.../orchestration/pipeline.py), но +-- НИКУДА его не пишет. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте +-- ("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30") — cian даже явно вырезает +-- location-часть перед записью (skip_types = {"location", "metro"}, providers/cian/serp.py). +-- Без явного города такой адрес при геокодинге считается «город не назван» → попадает +-- в EKB-only локальные реестры (ekb_geoportal_buildings/gendesign_cad_buildings) и +-- коллизирует с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни +-- совпадений) → объявление получает координаты Екатеринбурга и тянет медиану чужих цен. +-- +-- Fix: +-- Add listings.city TEXT column. Проставляется НЕПОСРЕДСТВЕННО из контекста +-- развёртки (город известен вызывающему коду — city_slug/CITY_LOCATIONS для oblast, +-- "Екатеринбург" для EKB-развёрток) — НЕ парсингом текста адреса. См. +-- scraper_kit.base.save_listings(..., city=...) + scraper_kit.orchestration.pipeline +-- .resolve_city_name(). Раздельная колонка (а не дописывание города в address) — +-- исходный текст адреса не портится, downstream text-парсеры (geocoder._parse_street_house, +-- geocoder._names_non_ekb_city, estimator._parse_street_house, house-matching) продолжают +-- работать НЕИЗМЕНЁННЫМИ на исходном сыром тексте — риск регрессии на bare-form адресах +-- без street-маркера ("Дружинина, 33") исключён. +-- +-- Scope (#2594): только write-path для НОВЫХ листингов (go-forward). Бэкфилл city для +-- уже накопленных строк (restore по тому, какая развёртка их когда-то принесла) — +-- отдельная задача, НЕ эта миграция. +-- +-- Idempotency: +-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS — safe on re-run. +-- BEGIN/COMMIT block. +-- +-- Dependencies: +-- 002_core_tables.sql (listings table). + +BEGIN; + +ALTER TABLE listings ADD COLUMN IF NOT EXISTS city text; + +COMMENT ON COLUMN listings.city IS + 'Город объявления (#2594) — проставляется из контекста развёртки ' + '(city_slug city-sweep / "Екатеринбург" default), НЕ парсингом address. ' + 'NULL — листинг записан до этой миграции ИЛИ путём, ещё не проставляющим город ' + '(admin ad-hoc /admin/scrape, manual ingest-скрипты).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql b/tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql new file mode 100644 index 00000000..490b4177 --- /dev/null +++ b/tradein-mvp/backend/data/sql/197_backfill_listings_city_from_url.sql @@ -0,0 +1,92 @@ +-- 197_backfill_listings_city_from_url.sql +-- Issue #2594 шаг 3 — бэкфилл listings.city (миграция 196) для УЖЕ накопленных +-- Avito-объявлений из слага города в source_url. +-- +-- ПРОБЛЕМА: 196 добавила колонку listings.city и write-path проставляет её +-- ТОЛЬКО для новых листингов (см. заголовок 196). Накопленные ранее строки +-- остались с city IS NULL. Для Avito-объявлений вне ЕКБ (city-sweep областных +-- городов) адрес в тексте часто без города («пр-т Вагоностроителей,18» вместо +-- «Нижний Тагил, пр-т Вагоностроителей,18»), а у части улиц есть тёзки в +-- Екатеринбурге (Хохрякова, Калинина — центральные ЕКБ-улицы). Без явного +-- city такой адрес при геокодировании (app/tasks/geocode_missing.py, +-- app/services/geocoder.py city_hint) считается «город не назван» → рискует +-- получить координаты Екатеринбурга (тот же баг-класс, что и #2594 основной). +-- Ночной прогон geocode_missing_listings 2026-08-01 заберёт в очередь 148 +-- активных объявлений Нижнего Тагила без city — этот бэкфилл проставляет им +-- city ДО того, как очередь начнёт их обрабатывать. +-- +-- ИСТОЧНИК: первый сегмент пути URL после хоста — +-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil' +-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')`. +-- Маппинг ТОЛЬКО наших шести городов Свердловской обл. (region 66); слаги и +-- человекочитаемые названия сверены с CITY_DISPLAY_NAMES/CITY_LOCATIONS +-- (tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py) +-- — значения побайтно совпадают с тем, что теперь пишет скрапер (go-forward +-- write-path 196), чтобы не расщепить один город на две разные метки. +-- +-- Проверено на проде (SELECT, read-only) перед миграцией: +-- avito_slug наш город city IS NULL (Avito) +-- 'ekaterinburg' -> 'Екатеринбург' 26770 +-- 'nizhniy_tagil' -> 'Нижний Тагил' 551 (148 сегодня в очереди геокода) +-- 'kamensk-uralskiy' -> 'Каменск-Уральский' 244 +-- 'pervouralsk' -> 'Первоуральск' 95 +-- 'verhnyaya_pyshma' -> 'Верхняя Пышма' 21 +-- 'serov' -> 'Серов' 25 +-- ИТОГО 27706 +-- ⚠️ avito_slug у Каменска-Уральского — ЧЕРЕЗ ДЕФИС ('kamensk-uralskiy'), не +-- через подчёркивание, в отличие от нашего внутреннего city_slug +-- 'kamensk_uralskiy' (CITY_LOCATIONS ключ). У Верхней Пышмы наоборот — +-- у Avito 'verhnyaya_pyshma' (kh -> h, БЕЗ 'k'), совпадает с +-- CityLocation("verhnyaya_pyshma", ...).avito_slug в pipeline.py, но +-- отличается от нашего внутреннего ключа 'verkhnyaya_pyshma' (с 'k'). +-- В фактических данных встретился ТОЛЬКО вариант 'verhnyaya_pyshma' — второй +-- вариант написания в WHERE не нужен (дал бы 0 доп. строк). +-- +-- ВНЕ SCOPE (сознательно не трогаем, обоснование): +-- - Cian: хост НЕ индикатор города (ekb.cian.ru отдаёт областные объявления, +-- включая тагильские, через тот же хост с параметром региона) — бэкфилл +-- по хосту дал бы неверный результат. +-- - Domclick: у объявлений без координат город не критичен (0 rows без +-- lat), 13 строк на голом domclick.ru — отдельный разбор, не эта миграция. +-- - Yandex: в URL (realty.yandex.ru/offer/) города нет вовсе. +-- - listings.region_code: у 16912 чужих-региона строк он неверный (стоит +-- 66) — отдельный пункт issue #2604, ждёт решения владельца, здесь НЕ +-- трогаем. +-- - Слаги вне наших шести городов (1644 distinct на Avito, 16930 строк +-- city IS NULL) остаются NULL — по ним отдельное решение владельца. +-- +-- Idempotency: +-- `WHERE city IS NULL` — не перетирает то, что уже проставил скрапер +-- (write-path 196) или предыдущий прогон этой же миграции. Повторный +-- прогон обновляет 0 строк (все затронутые строки уже НЕ city IS NULL). +-- CASE ветки строго совпадают со списком в WHERE ... IN (...), поэтому +-- для любой строки, прошедшей WHERE, CASE НЕ может вернуть NULL. +-- +-- НЕ DDL — только UPDATE данных (колонка listings.city уже существует, +-- миграция 196). Ни одна строка не удаляется и не деактивируется. +-- +-- Dependencies: 196_listings_city.sql (колонка listings.city). + +BEGIN; + +UPDATE listings +SET city = CASE substring(source_url from 'avito\.ru/([^/]+)/') + WHEN 'ekaterinburg' THEN 'Екатеринбург' + WHEN 'nizhniy_tagil' THEN 'Нижний Тагил' + WHEN 'kamensk-uralskiy' THEN 'Каменск-Уральский' + WHEN 'pervouralsk' THEN 'Первоуральск' + WHEN 'verhnyaya_pyshma' THEN 'Верхняя Пышма' + WHEN 'serov' THEN 'Серов' +END +WHERE source = 'avito' + AND city IS NULL + AND substring(source_url from 'avito\.ru/([^/]+)/') IN ( + 'ekaterinburg', + 'nizhniy_tagil', + 'kamensk-uralskiy', + 'pervouralsk', + 'verhnyaya_pyshma', + 'serov' + ); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql b/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql new file mode 100644 index 00000000..870d85bc --- /dev/null +++ b/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql @@ -0,0 +1,49 @@ +-- 198_scrape_proxy_rotations.sql +-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким +-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме +-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609). +-- +-- WHY: +-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя +-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation +-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток — +-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только +-- текущее состояние, не историю). +-- +-- Semantics: +-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый +-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет +-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до +-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring +-- за полным обоснованием "какие попытки считать против лимита"). +-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще). +-- +-- Idempotency: +-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон +-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет). + +BEGIN; + +CREATE TABLE IF NOT EXISTS scrape_proxy_rotations ( + id bigserial PRIMARY KEY, + proxy_id bigint NOT NULL REFERENCES scrape_proxies (id), + rotated_at timestamptz NOT NULL DEFAULT now(), + success boolean NOT NULL, + http_status integer, + note text +); + +COMMENT ON TABLE scrape_proxy_rotations IS + 'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks ' + 'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.' + 'proxy_rotation._quota_used_today считает только строки с http_status ' + 'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.'; + +-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at). +CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time + ON scrape_proxy_rotations (proxy_id, rotated_at); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql b/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql new file mode 100644 index 00000000..15c8e0dd --- /dev/null +++ b/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql @@ -0,0 +1,58 @@ +-- 199_scrape_proxies_asocks_rotate_url.sql +-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула, +-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться. +-- +-- WHY: +-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через +-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP +-- для ASocks-безлимитных портов — ручка веб-кабинета +-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком +-- Authorization: Bearer (env, НЕ в URL — секретов в миграции +-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для +-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация +-- session-суффиксом логина тоже не работает (проверено пробой, три варианта — +-- один и тот же exit-IP). +-- +-- Matching (важно — НЕ по id): +-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся +-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец +-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути, +-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через +-- right(url, length(hostport)) = hostport. portId → host:port (проверено +-- владельцем аккаунта, issue #2600 п.5): +-- 223610715 → 212.8.249.134:10423 +-- 225031312 → 190.2.145.131:10313 +-- 231878029 → 175.110.115.153:10492 +-- 231878030 → 109.236.82.42:11048 +-- +-- Idempotency: +-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по +-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) — +-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.rotate_url). + +BEGIN; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql b/tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql new file mode 100644 index 00000000..7aba4688 --- /dev/null +++ b/tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql @@ -0,0 +1,100 @@ +-- 200_region_code_foreign_cities.sql +-- Issue #2604 п.2 — убрать ложную метку региона у объявлений Avito из чужих +-- городов (Новосибирск, Казань, Челябинск, Тюмень и ещё ~1600 слагов). +-- +-- ПРОБЛЕМА: 16930 строк listings (source='avito') несут region_code = 66 +-- (Свердловская обл.), хотя source_url указывает на город ВНЕ наших шести — +-- это неправда. Строки — наследие массового заброса 18 июня (сплошной +-- multi-city SERP-краул до появления гео-фильтра карточек, коммит +-- f0264237, 20 июня), который с тех пор не проставлял target_city_slug на +-- SERP-запрос и не отсеивал карточки чужих городов на этапе сбора. Канал +-- давно закрыт (тот же класс проблемы, что чинили 196/197 для listings.city), +-- новых таких строк не поступает — все 16930 сейчас is_active = false. +-- +-- ПОЧЕМУ NULL, А НЕ НАСТОЯЩИЙ РЕГИОН: вывести реальный регион из текста +-- адреса/URL можно было бы (slug города в source_url), но это требовало бы +-- поддерживать растущий справочник ~1600 чужих региональных кодов ради +-- колонки, которую сегодня не читает НИ ОДНА живая выборка (проверено grep: +-- только исторические миграции 077_*/091_* и один комментарий). Честное +-- «неизвестно» (NULL) дешевле и не создаёт вторую ложь взамен первой. +-- +-- ПОЧЕМУ ТОЛЬКО AVITO: у cian/domklik/yandex region_code=66 определяется не +-- заброс-механизмом чужого города (там его и не было), а параметром region= +-- самого запроса (cian) / отсутствием городской привязки в URL вовсе +-- (domklik/yandex) — то есть в подавляющем большинстве region_code=66 у них +-- ВЕРНЫЙ. Среди них нашлось лишь 27 строк с адресом, похожим на чужой город +-- (текстовый разбор, ненадёжный сигнал) — сознательно НЕ трогаем, отдельная +-- задача при желании её довести. +-- +-- ИСТОЧНИК СЛАГА: первый сегмент пути после хоста — +-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil' +-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')` — +-- тот же идиом, что и в 197 (проверено: 'www.' перед 'avito.ru' в общий +-- матч не проваливается, слаг 'www' ни разу не извлёкся — все 45472 +-- source_url на проде имеют форму 'https://www.avito.ru/...'). Точный +-- сегмент пути, НЕ `LIKE '%slug%'` — среди наших шести слагов нет +-- подстрочных коллизий друг с другом (ekaterinburg, nizhniy_tagil, +-- kamensk-uralskiy, pervouralsk, verhnyaya_pyshma, serov — все взаимно +-- не substring), поэтому точное сравнение через WHERE ... NOT IN (...) над +-- извлечённым сегментом безопасно. +-- +-- Наши шесть слагов — АВИТОВСКОЕ написание (см. CityLocation(...).avito_slug +-- в packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py, +-- CITY_LOCATIONS ~ строки 330-336 + EKB default для 'ekaterinburg'): +-- kamensk-uralskiy — ЧЕРЕЗ ДЕФИС (не 'kamensk_uralskiy', наш внутренний +-- city_slug/CITY_LOCATIONS-ключ — через подчёркивание) +-- verhnyaya_pyshma — БЕЗ 'k' (не 'verkhnyaya_pyshma', наш внутренний ключ) +-- Побайтно сверено с 197_backfill_listings_city_from_url.sql, который решает +-- ту же задачу маппинга avito_slug -> наши города. +-- +-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией): +-- Наши шесть городов (НЕ должны попасть под UPDATE): 28542 строк +-- Кандидаты на UPDATE (source='avito', НЕ наши 6, region_code=66): +-- 16930 строк +-- из них is_active = false: 16930 (100%) +-- из них region_code = 66 (единственное текущее значение): 16930 (100%) +-- Avito-строк с region_code уже NULL среди кандидатов: 0 +-- (UPDATE их не задевает по построению — WHERE region_code IS NOT NULL) +-- Avito-строк с нераспознаваемым source_url (слаг не извлёкся): 0 +-- total avito = 45472 = 28542 (наши 6) + 16930 (кандидаты) — сходится. +-- +-- ПРОИЗВОДИТЕЛЬНОСТЬ: триггеры на listings — column-scoped +-- (`listings_price_change_trg` на UPDATE OF price_rub, +-- `listings_set_geom_trg` на UPDATE OF lat, lon) — UPDATE только по +-- region_code их не пробуждает. Но `tsv` (GENERATED ALWAYS ... STORED над +-- description+address) пересчитывается на КАЖДОМ UPDATE независимо от того, +-- какие колонки менялись. EXPLAIN (без ANALYZE, план не исполняется) на +-- проде показывает Bitmap Heap Scan по listings_source_idx (source='avito') +-- — тот же путь доступа, что и в 197. 197 обновила 27706 строк с тем же tsv +-- recalculation за 4.1с; здесь строк меньше (16930, ~61% от 27706) — +-- ожидаемая длительность ~2.5-3с. Никакого DDL, GIST/geom не затронуты. +-- +-- Idempotency: `AND region_code IS NOT NULL` — повторный прогон находит 0 +-- строк (все затронутые строки уже NULL после первого прогона), UPDATE +-- становится no-op. WHERE ограничен ровно source='avito' и slug вне наших +-- шести — наши города и другие источники никогда не попадают в scope. +-- +-- ГРАНИЦЫ: НЕ трогает region_code наших шести городов, НЕ трогает +-- cian/domklik/yandex/n1, НЕ трогает city/is_active/скраперы/ +-- DEFAULT_REGION_CODE. Ничего не удаляет, ничего не деактивирует. Только +-- UPDATE одной колонки одной таблицы. +-- +-- Dependencies: 002_core_tables.sql (listings.region_code — nullable int, +-- без DEFAULT на уровне таблицы). + +BEGIN; + +UPDATE listings +SET region_code = NULL +WHERE source = 'avito' + AND region_code IS NOT NULL + AND substring(source_url from 'avito\.ru/([^/]+)/') NOT IN ( + 'ekaterinburg', + 'nizhniy_tagil', + 'kamensk-uralskiy', + 'pervouralsk', + 'verhnyaya_pyshma', + 'serov' + ); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/201_purge_dead_mobileproxy_proxies.sql b/tradein-mvp/backend/data/sql/201_purge_dead_mobileproxy_proxies.sql new file mode 100644 index 00000000..8c9e275b --- /dev/null +++ b/tradein-mvp/backend/data/sql/201_purge_dead_mobileproxy_proxies.sql @@ -0,0 +1,83 @@ +-- 201_purge_dead_mobileproxy_proxies.sql +-- Issue #2613 — выпилить мёртвые узлы mobileproxy из пула scrape_proxies +-- вместе с чужим API-ключом, который лежал у них в rotate_url. +-- +-- WHY: +-- Владелец подтвердил: подписка mobileproxy закрыта, продлевать не будут. +-- Прямая проба каждого узла из контейнера tradein-scraper (2026-08-01) +-- подтверждает смерть: id 2 — connection refused, id 3/4/5 — 407 Proxy +-- Authentication Required. Последняя успешная проверка (last_check_at) у +-- всех четырёх — 4-9 июля, все четыре enabled=false, consecutive_fails=5. +-- +-- Две причины удалить, вторая важнее: +-- 1. Мёртвые узлы засоряют пул и его health-метрики. +-- 2. rotate_url у трёх из четырёх строк (id 3, 4, 5) хранит открытым +-- текстом чужой ключ провайдера в query-параметре ссылки ротации +-- (https://changeip.mobileproxy.space/?proxy_key=...). Именно из-за +-- неоднородности этой колонки (вперемешку с ASocks-строками, где +-- rotate_url — наш собственный API-эндпоинт БЕЗ секрета в URL, +-- авторизация Bearer-заголовком) глубокое ревью PR #2611 нашло +-- блокер: вызов ротации для такой строки отправил бы НАШ токен +-- ASocks на changeip.mobileproxy.space. Пин хоста в #2611 уже +-- закрывает саму уязвимость, но чужой секрет в базе держать незачем. +-- +-- ПОЧЕМУ DELETE, А НЕ UPDATE (очистка полей + enabled=false): +-- Единственный FK, ссылающийся на scrape_proxies — scrape_proxy_rotations +-- .proxy_id (заведён 198_scrape_proxy_rotations.sql), delete_rule NO ACTION. +-- На момент миграции (замер ниже) в scrape_proxy_rotations нет НИ ОДНОЙ +-- строки вообще — таблица введена в этом же цикле работ (#2600 п.5) и +-- ручная ротация ни разу не запускалась. DELETE четырёх строк scrape_proxies +-- ничего не упирает. Если бы к строкам 2-5 успела прилипнуть история ротаций +-- к моменту применения — DELETE упадёт по FK-violation ВНУТРИ этой же +-- транзакции (BEGIN/COMMIT ниже), миграция целиком откатится, deploy +-- завершится ошибкой (auto-apply strict, exit 1) без частичного эффекта и +-- без порчи данных; отдельного ON DELETE-обработчика не требуется — узлы +-- disabled=false уже сейчас, acquire() их не выдаёт (idx_scrape_proxies_pick +-- фильтрует по enabled), новых ротаций на них взяться неоткуда до deploy. +-- Строки — исторический мусор без ссылок, полное удаление честнее частичной +-- очистки (не оставляет призрачную запись мёртвого узла в пуле) и убирает +-- секрет из базы целиком, а не только из одной колонки. +-- +-- Matching (по домену url, НЕ по id): +-- id в scrape_proxies разъезжается между средами (bulk-загрузка независима +-- per-среда, тот же класс проблемы решён в 199 через host:port-matching). +-- Условие — WHERE url LIKE '%mobileproxy.space%' — ловит все четыре узла +-- независимо от порта/поддомена (ha./gi./auv./aup.mobileproxy.space) и не +-- заденет ASocks-строки (212.8.249.134 / 190.2.145.131 / 175.110.115.153 / +-- 109.236.82.42 — IP-адреса, без mobileproxy.space в url вовсе). +-- +-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией, 2026-08-01): +-- Строк под условие (url LIKE '%mobileproxy.space%'): 4 (id 2, 3, 4, 5) +-- Остаток пула после удаления (url NOT LIKE '%mobileproxy.space%'): +-- 4 (id 1, 9, 10, 11) — все ASocks +-- Строк в scrape_proxy_rotations на id 2/3/4/5: 0 +-- Строк в scrape_proxy_rotations всего (таблица пуста): 0 +-- Секрет-паттерн (token|bearer|secret|key=|password, regex +-- case-insensitive) в rotate_url ОСТАЮЩИХСЯ 4 строк: 0 совпадений +-- (rotate_url остающихся — https://api.asocks.com/unlimited-proxy/ +-- /refresh-ip, без query-параметров вообще, авторизация Bearer +-- заголовком вне URL, см. 199_scrape_proxies_asocks_rotate_url.sql) +-- FK, ссылающиеся на scrape_proxies: ровно один — +-- scrape_proxy_rotations.proxy_id -> scrape_proxies.id, delete_rule NO ACTION. +-- +-- Idempotency: +-- Обычный DELETE ... WHERE — повторный прогон находит 0 строк (уже +-- удалены), no-op. Весь файл в BEGIN/COMMIT. +-- +-- ГРАНИЦЫ: НЕ трогает ASocks-строки (id 1, 9, 10, 11) и их rotate_url. НЕ +-- трогает переменные окружения (*_PROXY_URL, BROWSER_PROXY_*, +-- *_PROXY_ROTATE_URL) — их снятие отдельная задача и НЕ раньше неё, иначе +-- при пустом прокси curl_proxy_url отдаёт None = скрапер идёт напрямую с IP +-- сервера. НЕ трогает app/services/proxy_pool.py, proxy_rotation.py, +-- скраперы. Никакого DDL. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.url/rotate_url/enabled). +-- 198_scrape_proxy_rotations.sql (FK proxy_id -> scrape_proxies.id, NO ACTION). + +BEGIN; + +DELETE FROM scrape_proxies +WHERE url LIKE '%mobileproxy.space%'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/202_listing_source_snapshot_budget_sec.sql b/tradein-mvp/backend/data/sql/202_listing_source_snapshot_budget_sec.sql new file mode 100644 index 00000000..04a5cc51 --- /dev/null +++ b/tradein-mvp/backend/data/sql/202_listing_source_snapshot_budget_sec.sql @@ -0,0 +1,38 @@ +-- 202_listing_source_snapshot_budget_sec.sql +-- #2607 — listing_source_snapshot зависал каждую ночь (минимум с 19 июля): scrape_runs +-- всегда добирал до 'zombie' ровно за 6h (порог zombie-детектора), но backend в Postgres +-- продолжал жечь CPU СУТКАМИ после этого (zombie-детектор в scraper_kit.orchestration. +-- scheduler.reap_zombies только помечает строку scrape_runs — не убивает backend), держа +-- backend_xmin и блокируя autovacuum на listings/listing_sources. +-- +-- ROOT CAUSE (тот же PR, app/tasks/listing_source_snapshot.py): event-diff CTE джойнил +-- "today" (снимок за CURRENT_DATE) с "prior" — DISTINCT ON по ВСЕЙ listing_source_snapshots +-- (~2.6-2.8M строк) обычным JOIN. Планировщик оценивал "today" в 1 строку (свежевставленные +-- в той же транзакции строки ANALYZE ещё не видел) → выбирал Nested Loop БЕЗ Materialize на +-- внутренней стороне → DISTINCT ON пересчитывался заново на КАЖДУЮ из ~80-140k реальных +-- строк today. EXPLAIN на проде: cost внутреннего подзапроса ~298 627. Запрос переписан на +-- JOIN LATERAL (per-row indexed point-lookup, cost ~4.4/строку) — устраняет корневую причину. +-- +-- ЭТА миграция — ДОПОЛНИТЕЛЬНЫЙ предохранитель (issue #2607 п.4): budget_sec в default_params +-- теперь читается snapshot_listing_sources() и выставляется как SET LOCAL statement_timeout +-- (per-transaction, НЕ server/role-level — тот отдельный вопрос issue #2607 п.2, требует +-- согласования, здесь намеренно не трогается). Если план когда-нибудь снова разрегрессирует, +-- прогон честно упадёт в mark_failed вместо того чтобы висеть сутками. +-- +-- 900 сек (15 мин) — по образцу migration 110 (geocode_missing_listings budget_sec=1800), +-- с большим запасом над ожидаемым временем выполнения после LATERAL-фикса (секунды) и +-- далеко от 6h zombie-порога и от окна 01:00-02:00 UTC (052/079). +-- +-- ЗАВИСИМОСТИ: 079_listing_source_history.sql (создаёт scrape_schedules row, source= +-- 'listing_source_snapshot', default_params='{}'::jsonb). +-- Idempotent: UPDATE ... || jsonb-merge — безопасно перезапускать (всегда приводит +-- default_params.budget_sec к 900 независимо от предыдущего состояния). +-- Apply after: 201_purge_dead_mobileproxy_proxies.sql + +BEGIN; + +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"budget_sec": 900}'::jsonb +WHERE source = 'listing_source_snapshot'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/203_purge_geocode_cache_house_letter.sql b/tradein-mvp/backend/data/sql/203_purge_geocode_cache_house_letter.sql new file mode 100644 index 00000000..d58586e2 --- /dev/null +++ b/tradein-mvp/backend/data/sql/203_purge_geocode_cache_house_letter.sql @@ -0,0 +1,52 @@ +-- Инвалидация записей geocode_cache, отравленных багом матчинга литеры дома. +-- +-- Контекст: `_cadastral_house_match` (app/services/geocoder.py) сравнивал дом +-- только по ЦИФРАМ — литера была опциональна в WHERE и участвовала лишь как +-- tie-break в ORDER BY. Итог, двусторонний: +-- • «Новгородцевой 13б» → «дом 13» (запрос с литерой → дом без неё) +-- • «Малышева 30» → «д. 30-б» (запрос без литеры → дом с литерой) +-- Оба результата писались с provider-тиром локального реестра и +-- `confidence='exact'`, TTL 90 дней → пользователь получал оценку ЧУЖОГО +-- здания, помеченную как точная, и она залипала в кэше. +-- +-- Здесь удаляем только ПОДОЗРИТЕЛЬНЫЕ строки, а не весь кэш: полная очистка +-- сожгла бы квоту внешних геокодеров (DaData 10k/день) на ре-резолв заведомо +-- корректных адресов. Удалённое будет пересчитано лениво, при следующем +-- запросе, уже исправленным матчером. +-- +-- Идемпотентность: чистый DELETE по предикату. Повторный прогон удалит 0 строк +-- (первый уже вычистил всё подходящее), новых строк с такой же патологией +-- исправленный код не создаёт. Безопасно для strict exit-1 авто-применения. + +BEGIN; + +DELETE FROM geocode_cache +WHERE + -- (a) В самом запросе была литера дома: под старым матчером такой адрес мог + -- уехать в дом без литеры / с чужой литерой. Смотрим на ХВОСТ адреса + -- (дом пишется последним) — иначе порядковые части улиц («1-я + -- Пятилетки», «4-й Кианитовый») ложно читались бы как литера. + -- `|city=` — суффикс ключа кэша (см. geocoder._cache_key), отрезаем. + split_part(address_normalized, '|city=', 1) ~* '[0-9]+\s*-?\s*[а-яё]\s*$' + + -- (b) Обратное направление: в запросе литеры НЕ было, а закэширован адрес + -- реестра, у которого номер дома С литерой («Малышева 30» → «д. 30-б»). + -- Извлечение номера — то же выражение, что и в исправленном матчере + -- (geocoder._SQL_HOUSE_TOKEN_RE): маркер только с начала слова, литера + -- — одиночная кириллическая буква, «58/3»/«64-2» литерой не считаются. + OR ( + split_part(address_normalized, '|city=', 1) !~* '[0-9]+\s*-?\s*[а-яё]\s*$' + AND full_address IS NOT NULL + AND regexp_replace( + regexp_replace( + lower(COALESCE((regexp_match( + full_address, + '\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*' + || '([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)', + 'i'))[1], '')), + '\s', '', 'g'), + '-([а-яё])', '\1', 'g' + ) ~ '[а-яё]$' + ); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql b/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql new file mode 100644 index 00000000..146bca44 --- /dev/null +++ b/tradein-mvp/backend/data/sql/204_cian_oblast_sweeps_secondary.sql @@ -0,0 +1,87 @@ +-- 204_cian_oblast_sweeps_secondary.sql +-- Включить сбор вторички Циана по 4 областным city-sweep'ам (Свердловская обл., +-- миграция 179 — nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov). +-- +-- ПРОБЛЕМА: _job_cian_city_sweep (scraper_kit.orchestration.scheduler:570) читает +-- newbuilding_only = bool(default_params.get("newbuilding_only", True)) — дефолт True. +-- run_cian_city_sweep (pipeline.py:2436) фильтрует SERP-результат на +-- listing_segment == "novostroyki" ДО save_listings, вторичку отбрасывает +-- (counters.lots_dropped_secondary). +-- +-- Дефолт осмыслен для ЕКБ: docstring run_cian_city_sweep прямо говорит, что +-- вторичку авторитетно собирает run_cian_full_load (exhaustive региональный сбор). +-- НО run_cian_full_load (pipeline.py:2790) хардкодит city=EKATERINBURG_CITY_NAME — +-- параметра города там нет вообще, область не покрывает. Итог: областную вторичку +-- Циана не собирает НИКТО (городская развёртка её выбрасывает, full_load туда не +-- ходит) — областные schedule'ы склонированы с ЕКБ (миграция 179) и унаследовали +-- предположение, которое для них неверно. +-- +-- Прод-счётчики (scrape_runs.counters, последние runs на 2026-08-02) подтверждают: +-- pervouralsk 55 увидено, 53 выброшено (сохранено 2) +-- kamensk_uralskiy 113 увидено, 108 выброшено (сохранено 5) +-- nizhniy_tagil 184 увидено, 176 выброшено (сохранено 3) +-- verkhnyaya_pyshma 38 увидено, 16 выброшено (сохранено 9) -- см. EXCLUSION ниже +-- +-- FIX: newbuilding_only: false для ЧЕТЫРЁХ областных source'ов. cian_city_sweep (ЕКБ, +-- БЕЗ суффикса города) НЕ трогаем — для него дефолт корректен (вторичку ЕКБ +-- собирает cian_full_load), включение дало бы дублирующую нагрузку на источник. +-- +-- !!! EXCLUSION: cian_city_sweep_verkhnyaya_pyshma НЕ включён в эту миграцию !!! +-- Верхняя Пышма физически ~15 км от центра Екатеринбурга — geo-проверка по +-- ST_DWithin (координаты listings vs центр города) показала, что 5 из 22 (23%) +-- текущих cian-строк с меткой city="Верхняя Пышма" физически лежат в 15 км от +-- центра ЕКБ, т.е. это загрязнённая городская разметка (sweep по anchor'у В.Пышмы +-- зацепляет краевые екатеринбургские объявления и подписывает их не тем городом). +-- Колонка listings.city — money-critical: её читает asking_to_sold_ratio.py +-- (city-скоуп ASKING vs SOLD стороны, #2583 H2) — неверная метка двигает выкупные +-- цены. При newbuilding_only=false объём cian-строк под меткой В.Пышма вырастет с +-- 22 до нескольких сотен (те же ~38 увидено/16 выброшено за один run, помноженные +-- на число прогонов) — 23%-загрязнение умножилось бы пропорционально. +-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov — загрязнение по той же +-- geo-проверке НУЛЕВОЕ (0 из 8/5/2 соответственно физически в ЕКБ) — включать +-- безопасно. cian_city_sweep_verkhnyaya_pyshma будет включён ОТДЕЛЬНОЙ миграцией +-- после починки городской разметки sweep'а (правится параллельно) — НЕ забыт. +-- +-- Нагрузка на источник (см. PR description / vault fix-запись для полного разбора): +-- fetch_around_multi_room (providers/cian/serp.py:209) НЕ принимает newbuilding_only/ +-- secondary_only — SERP-фаза (все rooms×pages) выполняется ОДИНАКОВО независимо от +-- этого флага. Фильтр в pipeline.py:2436 применяется ПОСЛЕ фетча, ДО save — чисто +-- in-memory отсечение уже оплаченных запросов. HTTP-нагрузка на cian.ru НЕ меняется; +-- меняется только объём save_listings (DB-writes) — на порядок больше СОХРАНЯЕМЫХ +-- строк, не больше запросов к источнику. detail_top_n=10 detail-фетчей тоже не растёт +-- (LIMIT :lim константен, лишь конкурирующий пул кандидатов расширяется). +-- +-- Дубли: run_cian_full_load всегда region_code=EKB (city_region_id=4743 через +-- CianScraper() без city_slug), областные sweeps используют CITY_LOCATIONS[] +-- .cian_region_id (4886/4781/4925/4982 — все != 4743) — SERP-запросы физически +-- разных региональных выдач. dedup_hash = sha256(source|source_id) — глобальный +-- Cian offer_id, ON CONFLICT (dedup_hash) DO UPDATE — даже в теоретическом edge-case +-- совпадения upsert НЕ создаёт дубль-строку. +-- +-- listing_segment: providers/cian/serp.py:892 — вторичка получает +-- listing_segment = "vtorichka" (НЕ NULL) → проходит фильтр +-- "listing_segment IS NULL OR listing_segment = 'vtorichka'" в asking_to_sold_ratio.py +-- и buildings_query.py — новые лоты попадут в оценку без доп. кода. +-- +-- Мердж jsonb (COALESCE || ...), НЕ перезапись — сохраняет city/radius_m/detail_top_n/ +-- enrich_houses/pages_per_anchor/request_delay_sec (см. 179_scrape_schedules_seed_oblast_city_sweeps.sql +-- за текущими прод-значениями). Idempotent: повторный прогон ставит то же значение. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица), 179 (seed этих source'ов). +-- deploy order: только миграция — код scheduler.py/pipeline.py НЕ меняется в этом PR, +-- дефолт newbuilding_only=True в коде остаётся (правильный fallback для будущих +-- source'ов без явного default_params override). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"newbuilding_only": false}'::jsonb +WHERE source IN ( + 'cian_city_sweep_nizhniy_tagil', + 'cian_city_sweep_kamensk_uralskiy', + 'cian_city_sweep_pervouralsk', + 'cian_city_sweep_serov' +); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/205_sales_vs_listings_city_filter.sql b/tradein-mvp/backend/data/sql/205_sales_vs_listings_city_filter.sql new file mode 100644 index 00000000..116a4a02 --- /dev/null +++ b/tradein-mvp/backend/data/sql/205_sales_vs_listings_city_filter.sql @@ -0,0 +1,222 @@ +-- 205_sales_vs_listings_city_filter.sql +-- Purpose: #2583 H4 — street_sales_vs_listings() (067) строит пары «ДКП-сделка ↔ +-- listing» через LEFT JOIN, где условие матчинга — ТОЛЬКО street_pattern (ILIKE) + +-- rooms + area ±tolerance + дата. Городской корреляции нет вообще: deals.address / +-- listings.address хранят "<Город>, <Улица>" (Росреестр агрегирует до улицы, без +-- дома), а street_pattern = голое имя улицы («Ленина», «Красноармейская», +-- «Советская» — десятки одноимённых улиц в разных городах обл.66). ILIKE +-- '%Ленина%' матчит "Нижний Тагил, Ленина" И "Екатеринбург, Ленина" одинаково — +-- пара выбирается ближайшей по дате, город игнорируется. +-- +-- Прод-репро (см. PR-описание): street='Ленина', rooms=2, area≈44.3м², defaults — +-- 352 total pairs по всем городам, 244 с listing-match, из них 119 (49%) явно +-- чужого города (deal.city <> listing.city, обе стороны известны) + 122 (50%) с +-- listing.city IS NULL (Циан/Домклик/Яндекс, город неизвестен — потенциально тоже +-- чужой). Для Нижнего Тагила конкретно: 8 сделок получили match, 4 — явно чужой +-- город (ЕКБ и др.). median_discount_pct на смеси городов уезжает в -63.6% +-- (в audit-заходе см. #2583 -59%) — «медианный торг» на витрине читается как +-- реальная рыночная скидка по улице пользователя, а на деле мешает рынки разной +-- ценовой полки. +-- +-- Соседний эндпоинт /street-deals (trade_in.py:1654) городской скоуп уже получил +-- (комментарий #C1 там же) — тот же паттерн переносим сюда: город резолвится +-- ОДИН раз в Python через _resolve_target_city(address) (estimator.py:1350, +-- словарь ~30 городов обл.66 вкл. ЕКБ + sweep-города) и передаётся как ОДИН +-- bind-параметр в TVF, который применяет его к ОБЕИМ сторонам JOIN: +-- - deals.city заполнена на 100% (проверено на проде) → строгое равенство +-- LOWER(d.city) = LOWER(p_target_city). +-- - listings.city заполнена ЧАСТИЧНО (прод-замер: avito 63%, yandex 19%, +-- cian 4.6%, domklik 0.6%, n1 0%) → предикат терпим к NULL, симметрично +-- паттерну asking_to_sold_ratio.py (#2583 H2, PR #2617): +-- (l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)). +-- Строгий `l.city = p_target_city` без IS NULL выбросил бы ~80-95% listings +-- для источников кроме avito — по мере роста покрытия колонки предикат сам +-- ужесточается без правок кода. +-- - p_target_city IS NULL (адрес вне словаря SVERDLOVSK_OBLAST_CITIES, редкий +-- мелкий н.п. области — тот же неполный список, что в известной находке H1) +-- → фильтр не применяется НИ на одной стороне, текущее (pre-fix) поведение +-- сохраняется как fallback. Осознанно, не побочный эффект: /street-deals уже +-- принял этот компромисс для того же словаря городов — расхождение в +-- поведении между двумя виджетами на одной странице (для одного и того же +-- адреса) было бы хуже, чем редкий edge-case без фильтра. H1 — известная +-- отдельная находка (fix отдельным PR), здесь её не трогаем. +-- +-- Signature change: p_target_city добавлен СЕДЬМЫМ параметром с DEFAULT NULL — +-- обратная совместимость с любым caller'ом, который вызывает функцию 6 +-- позиционными аргументами (сейчас единственный caller — trade_in.py:1865, +-- обновляется в этом же PR). CREATE OR REPLACE FUNCTION с ДОБАВЛЕННЫМ параметром +-- технически создаёт НОВУЮ перегрузку (Postgres матчит функции по списку типов +-- аргументов) — поэтому старую 6-параметровую сигнатуру дропаем явно ПЕРЕД +-- CREATE OR REPLACE, чтобы не остались висеть два оверлоада одной функции. +-- DROP FUNCTION IF EXISTS с 6-арг сигнатурой идемпотентен: при повторном +-- прогоне (когда функция уже 7-арг) просто no-op, ошибки не будет. +-- +-- Grep-проверка вызывающих (2026-08): единственный caller — +-- app/api/v1/trade_in.py:1865 (/sales-vs-listings). Convenience view +-- v_street_sales_vs_listings из 067 уже дропнута в 068 (была без street-match, +-- генерила 50k spurious pairs) — фиксить нечего, объекта не существует. +-- +-- Deploy order: после 204. Второй caller (Python) обновляется в том же PR — +-- миграция должна применяться ДО деплоя backend-кода (стандартный SQL-first +-- порядок), но т.к. новый параметр DEFAULT NULL — старый код (без city) продолжит +-- работать без ошибок между миграцией и деплоем кода (не критичный порядок, но +-- соблюдаем канон). + +BEGIN; + +DROP FUNCTION IF EXISTS street_sales_vs_listings(text, numeric, integer, integer, numeric, integer); + +CREATE OR REPLACE FUNCTION street_sales_vs_listings( + p_street_pattern text, + p_area_m2 numeric, + p_rooms integer, + p_window_days integer DEFAULT 180, + p_area_tolerance numeric DEFAULT 0.15, + p_period_months integer DEFAULT 24, + p_target_city text DEFAULT NULL +) +RETURNS TABLE ( + deal_id bigint, + deal_date date, + deal_price_rub bigint, + deal_price_per_m2 integer, + deal_area_m2 numeric, + deal_rooms integer, + deal_floor integer, + deal_address text, + listing_id bigint, + listing_source text, + listing_source_url text, + listing_date date, + listing_price_rub bigint, + listing_price_per_m2 integer, + listing_area_m2 numeric, + days_listing_to_deal integer, + discount_pct numeric +) +LANGUAGE sql +STABLE +AS $$ + WITH window_deals AS ( + -- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city. + SELECT + d.id AS deal_id, + d.deal_date AS deal_date, + d.price_rub AS deal_price_rub, + d.price_per_m2 AS deal_price_per_m2, + d.area_m2 AS deal_area_m2, + d.rooms AS deal_rooms, + d.floor AS deal_floor, + d.address AS deal_address + FROM deals d + WHERE d.source = 'rosreestr' + AND d.address ILIKE p_street_pattern + AND d.rooms = p_rooms + AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND d.deal_date > NOW() - (p_period_months || ' months')::interval + AND d.price_rub > 0 + -- #2583 H4: deals.city заполнена на 100% — строгое равенство. + -- NULL p_target_city (город вне словаря) → фильтр не применяется. + AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city)) + ), + window_listings AS ( + -- Кандидаты-listings на той же улице, rooms exact, area ±tolerance, + -- (#2583 H4) тот же город что deals-сторона. + SELECT + l.id AS listing_id, + l.source AS listing_source, + l.source_url AS listing_source_url, + l.listing_date AS listing_date, + l.price_rub AS listing_price_rub, + l.price_per_m2 AS listing_price_per_m2, + l.area_m2 AS listing_area_m2, + l.rooms AS listing_rooms, + COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date + FROM listings l + WHERE l.address ILIKE p_street_pattern + AND l.rooms = p_rooms + AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND l.price_rub > 0 + AND COALESCE(l.listing_date, l.scraped_at::date) + > NOW() - ((p_period_months + 6) || ' months')::interval + -- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%, + -- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим" + -- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий + -- фильтр выбросил бы почти все listings кроме avito. + AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)) + ), + paired AS ( + -- LEFT JOIN: сохраняем все сделки даже если нет listing match. + -- Для каждой сделки выбираем listing с listing_date ближайший + -- к deal_date (предпочтительно перед сделкой). + SELECT DISTINCT ON (wd.deal_id) + wd.deal_id, + wd.deal_date, + wd.deal_price_rub, + wd.deal_price_per_m2, + wd.deal_area_m2, + wd.deal_rooms, + wd.deal_floor, + wd.deal_address, + wl.listing_id, + wl.listing_source, + wl.listing_source_url, + wl.listing_date, + wl.listing_price_rub, + wl.listing_price_per_m2, + wl.listing_area_m2, + (wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal, + CASE + WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0 + THEN ROUND( + (wd.deal_price_rub - wl.listing_price_rub)::numeric + / wl.listing_price_rub * 100, + 2 + ) + ELSE NULL + END AS discount_pct + FROM window_deals wd + LEFT JOIN window_listings wl + ON wl.listing_event_date + BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date + AND (wd.deal_date + interval '30 days')::date + ORDER BY + wd.deal_id, + -- prefer listing event дата перед сделкой и ближе к ней + CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END, + CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END, + ABS((wd.deal_date - wl.listing_event_date)) + ) + SELECT + deal_id, + deal_date, + deal_price_rub, + deal_price_per_m2, + deal_area_m2, + deal_rooms, + deal_floor, + deal_address, + listing_id, + listing_source, + listing_source_url, + listing_date, + listing_price_rub, + listing_price_per_m2, + listing_area_m2, + days_listing_to_deal, + discount_pct + FROM paired + ORDER BY deal_date DESC; +$$; + +COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS + 'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, ' + 'city-filter #2583 H4 (миграция 205). Per-street matching: address ILIKE, area ' + '±tolerance, rooms exact, window_days до даты сделки (+30д grace), city-scope ' + '(p_target_city, deals строго / listings терпимо к NULL). Возвращает LEFT ' + 'JOIN — сделки без listing match имеют listing_* = NULL. discount_pct = ' + '(deal - listing) / listing * 100.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/206_scrape_schedules_cut_wasteful_load.sql b/tradein-mvp/backend/data/sql/206_scrape_schedules_cut_wasteful_load.sql new file mode 100644 index 00000000..2f3c739a --- /dev/null +++ b/tradein-mvp/backend/data/sql/206_scrape_schedules_cut_wasteful_load.sql @@ -0,0 +1,136 @@ +-- 206_scrape_schedules_cut_wasteful_load.sql +-- Срезать бесполезную нагрузку на источники (=нагрузку на единственный живой общий +-- прокси: scrape_proxies enabled=true AND provider_affinity='any' → ровно 1 узел +-- asocks-mobile-2 на момент этой миграции; asocks-residential-1 закреплён отдельно +-- за domclick). Только UPDATE scrape_schedules.default_params / .enabled — код +-- скраперов НЕ меняется. Все цифры ниже — прод, scrape_runs.counters, 30 дней +-- (2026-08-02), проверено read-only перед написанием файла. +-- +-- 1) cian_full_load — 110.0 ч из 30-дневного окна (29 runs), доминирующий потребитель +-- прокси-времени в системе. Текущий default_params подтверждён на проде: +-- concurrency=5, request_delay_sec=4.0 → эффективный интервал 4.0/5=0.8с между +-- запросами. 30-дневные counters: unique_fetched=44011, saved_inserted=1682, +-- saved_updated=32862 — сигнал реальный (НЕ нулевой выхлоп), но темп избыточен +-- относительно ценности. concurrency 5→2, request_delay_sec 4.0→6.0 даёт +-- эффективный интервал 6.0/2=3.0с (в 3.75 раза медленнее); interval_days 1→3 +-- (daily → раз в 3 дня) сокращает число прогонов в 3 раза. Совместно — падение +-- запросов к Циану на порядок, в духе оценки задачи (~11-16 тыс./сутки → ~1-1.5 тыс.). +-- Потеря свежести: ~1095 price-update/сутки в среднем откладываются на срок до +-- 2 суток между прогонами — не исчезают, детектируются позже; оценщик использует +-- LISTINGS_FRESH_DAYS=14, лаг в 1-2 дня внутри этого окна некритичен. +BEGIN; + +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"concurrency": 2, "request_delay_sec": 6.0, "interval_days": 3}'::jsonb +WHERE source = 'cian_full_load'; + +-- 2) avito_full_load — request_delay_sec=1.0, самая агрессивная настройка в конфиге +-- (подтверждено). 30-дневные scrape_runs.status: 20/30 banned мгновенно +-- (done_buckets=[], 0 fetched), 9/30 failed (0 fetched), 1/30 done (357 inserted / +-- 3397 updated). avito_full_load_exhaustive — тот же traversal, но УЖЕ на +-- request_delay_sec=7.0 + interval_days=7 — тем не менее 4/4 runs banned за 30д: +-- сама скорость запроса не единственная причина бана (вероятно паттерн полного +-- обхода всех room×price buckets), но замедление всё равно валидно снижает +-- бесполезную нагрузку на прокси при каждой попытке. request_delay_sec 1.0→7.0 +-- (уравнено с городскими развёртками) и interval_days 1→7 (недельный такт, решение +-- по данным — см. § "рассмотри и перевод на недельный такт, но реши по данным": +-- ежедневный прогон 29 из последних 30 раз не даёт НИ ОДНОЙ новой/обновлённой +-- строки, недельный такт не теряет свежести, которой и так нет). +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"request_delay_sec": 7.0, "interval_days": 7}'::jsonb +WHERE source = 'avito_full_load'; + +-- 3a) yandex_address_backfill (отдельная джоба) — 30-дневные counters: checked=6000, +-- saved=4 (0.07%), errors=406. Ненулевой поток (4 записи/мес) — по границам задачи +-- НЕ выключаем полностью, переводим на недельный такт (1→7). +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"interval_days": 7}'::jsonb +WHERE source = 'yandex_address_backfill'; + +-- 3b) address-enrich ФАЗА ВНУТРИ yandex_city_sweep (ЕКБ) — run_yandex_city_sweep() +-- принимает enrich_address: bool (orchestration/pipeline.py:1870), scheduler.py:545 +-- читает его ИМЕННО из default_params.get("enrich_address", True) — управляется +-- параметром, правка кода НЕ требуется (в отличие от того, если бы флаг был +-- захардкожен — этого на проверке НЕТ, поэтому трогаем только данные). +-- 30-дневные counters ТОЛЬКО для source='yandex_city_sweep' (ЕКБ, без city-суффикса): +-- address_attempted=5829, address_enriched=0, address_failed=40 — фаза полностью +-- впустую. ВАЖНО: 5 областных yandex_city_sweep_ за те же 30 дней показывают +-- address_attempted=0 (фаза там и так не тратит запросы — не из-за enrich_address, +-- а потому что WHERE-условие backfill'а — address IS NOT NULL AND NOT ~ ',\s*\d+' — +-- там просто ничего не находит) — их НЕ трогаем, нечего чинить по данным. +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"enrich_address": false}'::jsonb +WHERE source = 'yandex_city_sweep'; + +-- 4) house_imv_backfill — 30-дневные counters: checked=1500, saved=44 (2.9%), +-- errors=1301 (87%), skipped=155. Ненулевой поток — НЕ выключаем (граница задачи), +-- втрое снижаем частоту (1→3 дня) до отдельного разбора причины 87%-ошибок — +-- сокращает объём бесполезных попыток пропорционально при сохранении прогресса +-- по валидным 13%. +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"interval_days": 3}'::jsonb +WHERE source = 'house_imv_backfill'; + +-- 5) domclick_detail_backfill — 30-дневные counters: attempted=491, enriched=0, +-- failed=431, blocked=60 — 100% впустую (0 обогащений вообще), включая на +-- ДЕДИКЕЙТЕД прокси (scrape_proxies.provider_affinity='domclick', +-- asocks-residential-1) — тот прокси тоже палится в никуда. Полностью выключаем +-- до починки (единственный пункт этой миграции, где нулевой выход подтверждён +-- буквально — enabled=false оправдан границей задачи). +UPDATE scrape_schedules +SET enabled = false +WHERE source = 'domclick_detail_backfill'; + +-- 6) yandex_newbuilding_sweep — 30/30 runs status=done, но rows_inserted=0 во ВСЕХ +-- (failed_resolve стабильно ~4-5/run, backlog pending растёт 351→367 за 30д — +-- джоба не успевает и не разбирает очередь). interval_days 1→7. +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"interval_days": 7}'::jsonb +WHERE source = 'yandex_newbuilding_sweep'; + +-- 7) Областные развёртки (15 job'ов = 5 городов × {avito,cian,yandex}_city_sweep_, +-- см. миграцию 179) — ежедневно → раз в 3 дня. Независимая проверка (НЕ те же цифры, +-- что в задаче — посчитано отдельно по listings_snapshots за последние 14 дней для +-- ~1245 активных объявлений в 5 областных городах): 4 события изменения цены на +-- 2282 снапшот-строки = ~0.023%/сутки — НИЖЕ заявленных в задаче 0.15%/сутки, +-- подтверждает избыточность daily-такта. cian_city_sweep (ЕКБ, БЕЗ суффикса города, +-- id=128) и его newbuilding_only-логику НЕ трогаем (недавно правились, вне периметра +-- этой миграции). Потеря свежести: при трёхдневном такте цена/новый лот в областном +-- городе детектируется с лагом до 2 суток — при ~0.02-0.15%/сутки волатильности и +-- LISTINGS_FRESH_DAYS=14 эффект на оценку пренебрежим. +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) + || '{"interval_days": 3}'::jsonb +WHERE source IN ( + 'avito_city_sweep_nizhniy_tagil', + 'avito_city_sweep_kamensk_uralskiy', + 'avito_city_sweep_pervouralsk', + 'avito_city_sweep_verkhnyaya_pyshma', + 'avito_city_sweep_serov', + 'cian_city_sweep_nizhniy_tagil', + 'cian_city_sweep_kamensk_uralskiy', + 'cian_city_sweep_pervouralsk', + 'cian_city_sweep_verkhnyaya_pyshma', + 'cian_city_sweep_serov', + 'yandex_city_sweep_nizhniy_tagil', + 'yandex_city_sweep_kamensk_uralskiy', + 'yandex_city_sweep_pervouralsk', + 'yandex_city_sweep_verkhnyaya_pyshma', + 'yandex_city_sweep_serov' +); + +-- НЕ тронуто (сознательно, данные не подтвердили действие): +-- avito_detail_backfill (2295 attempted / 494 enriched = 78% брака, но 494 +-- обогащения/мес — реальный, не близкий к нулю поток; вне "Предлагаемого набора" +-- задачи, полноценно вне периметра этой миграции). +-- cian_city_sweep (ЕКБ) / newbuilding_only — явный запрет задачи. +-- yandex_city_sweep_ (5 областных) enrich_address — address_attempted=0 там, +-- нечего выключать. + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/207_backfill_yandex_cian_city_geo_cleanup.sql b/tradein-mvp/backend/data/sql/207_backfill_yandex_cian_city_geo_cleanup.sql new file mode 100644 index 00000000..70f0bd4b --- /dev/null +++ b/tradein-mvp/backend/data/sql/207_backfill_yandex_cian_city_geo_cleanup.sql @@ -0,0 +1,124 @@ +-- 207_backfill_yandex_cian_city_geo_cleanup.sql +-- Issue #2628 — бэкфилл-чистка неверных oblast-городских меток `listings.city`, +-- накопленных ДО write-time гео-guard'а (PR #2626, `save_listings(..., city_anchor=..., +-- city_radius_km=...)`, packages/scraper-kit/src/scraper_kit/base.py). +-- +-- ПРОБЛЕМА: upsert `ON CONFLICT` делает `city = COALESCE(EXCLUDED.city, listings.city)` +-- (base.py:589 / base.py:701) — новый `NULL` от гео-guard'а НЕ перетирает уже записанную +-- неверную метку. Строки, помеченные до #2626 (когда guard'а ещё не было), несут +-- ошибочный город БЕСКОНЕЧНО (каждый повторный upsert сохраняет старое значение). +-- +-- ⛔ ГЛАВНОЕ ОГРАНИЧЕНИЕ (issue #2628) — гео-критерий ТОЛЬКО для yandex/cian: +-- Координаты Avito на SERP-этапе — систематически ЕКБ-центроид геокодера +-- (lat=lon=None у карточки, `save_listings` пишет их ДО фазы деталей; +-- провайдерские координаты у Avito на этом этапе попросту отсутствуют/неверны). +-- При этом ТЕКСТ адреса у Avito город называет верно (Каменск 214/222, +-- Тагил 389/506, Серов 30/30, Пышма 24/26 строк). Наивный гео-критерий по ВСЕМ +-- источникам снёс бы ~640 корректных Avito-меток. yandex/cian отдают реальные +-- провайдерские координаты — точность гео-критерия там подтверждена (113/115). +-- +-- Гео-критерий — ТОТ ЖЕ, что использует write-time guard (никакой параллельной +-- логики): per-city anchor (lat, lon) + радиус (км), haversine-расстояние. +-- Anchors/радиусы скопированы 1:1 из +-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +-- (`CITY_ANCHORS`, `_DEFAULT_CITY_STAMP_RADIUS_KM` = 15.0, +-- `_CITY_STAMP_RADIUS_KM['verkhnyaya_pyshma']` = 8.0 — Пышма ~15.3км от ЕКБ, +-- дефолтный 15км-порог у неё никогда бы не сработал). Формула — та же +-- `_haversine_km` (сферическая Земля, r=6371.0км), что использует +-- `save_listings` в base.py. +-- +-- Гео-guard write-time НИКОГДА не применяется к строкам с city="Екатеринбург" +-- (get_city_anchor_point возвращает None для ЕКБ/неизвестного slug — "в регионе +-- нет города КРУПНЕЕ ЕКБ, чей SERP мог бы её поглотить", pipeline.py docstring) +-- — здесь тем же принципом трогаем ТОЛЬКО 5 oblast-меток из CITY_DISPLAY_NAMES, +-- никогда не Екатеринбург. +-- +-- Два известных ложных срабатывания ГОЛОГО гео-критерия (issue #2628, п.3) — +-- НЕ трогать: +-- - "Верхняя Пышма, улица Орджоникидзе, 1" — 14,4км (> 8км-порог Пышмы) +-- - "Первоуральск, Береговая улица, 7А" — 28,1км (> 15км-порог) +-- Оба — реальные адреса СВОЕГО города (городской округ географически больше +-- компактного ядра, для которого калиброван радиус), метка верна несмотря на +-- расстояние. Защита — issue #2628 п.2: если ТЕКСТ адреса называет город +-- метки, метку не трогаем НЕЗАВИСИМО от координат. Python-парсер топонимов +-- `_names_non_ekb_city` (app/services/geocoder.py) — word-boundary regex по +-- списку из 37 городов с district-префикс исключениями — НЕ переносится в SQL +-- один-в-один без дублирования списка/regex-семантики. Вместо этого — простой +-- `address ILIKE '%<город_метки>%'` (issue #2628, разрешённый fallback): +-- проверяем, что адрес называет ИМЕННО тот город, который уже стоит в +-- `listings.city` (не произвольный топоним) — этого достаточно, чтобы +-- накрыть оба false positive (оба явно начинаются с имени своего города в +-- тексте адреса) без переизобретения гео-парсера в SQL. +-- +-- Масштаб (issue #2628): ~115 активных строк yandex/cian получат city = NULL. +-- НЕ удаляем строки — только обнуляем метку (NULL считается "своим" в +-- money-path `asking_to_sold_ratio.py:124`, `city IS NULL OR city ILIKE +-- :asking_city` — обнуление не выбрасывает лот из выборки, только убирает +-- его из ЧУЖОЙ (oblast) выборки). +-- +-- Idempotency: +-- WHERE l.city = ca.city_name — после первого прогона обнулённые строки +-- (city IS NULL) больше не матчат ни один city_name → повторный прогон +-- обновляет 0 строк. +-- +-- НЕ DDL — только UPDATE данных существующей колонки (196_listings_city.sql). +-- +-- Dependencies: 196_listings_city.sql (колонка listings.city). + +BEGIN; + +-- Dry-run (READ-ONLY) — тот же WHERE, что и UPDATE ниже. Прогонять ОТДЕЛЬНО +-- (вне транзакции миграции) для верификации масштаба до/после мержа: +-- +-- WITH city_anchor (city_name, anchor_lat, anchor_lon, radius_km) AS (VALUES +-- ('Нижний Тагил', 57.910::double precision, 59.980::double precision, 15.0::double precision), +-- ('Каменск-Уральский', 56.414::double precision, 61.918::double precision, 15.0::double precision), +-- ('Первоуральск', 56.908::double precision, 59.943::double precision, 15.0::double precision), +-- ('Верхняя Пышма', 56.976::double precision, 60.578::double precision, 8.0::double precision), +-- ('Серов', 59.604::double precision, 60.578::double precision, 15.0::double precision) +-- ) +-- SELECT l.source, l.city, count(*) +-- FROM listings l +-- JOIN city_anchor ca ON ca.city_name = l.city +-- WHERE l.source IN ('yandex', 'cian') +-- AND l.is_active = true +-- AND l.lat IS NOT NULL +-- AND l.lon IS NOT NULL +-- AND l.address NOT ILIKE '%' || ca.city_name || '%' +-- AND 2 * 6371.0 * asin(sqrt( +-- power(sin(radians(ca.anchor_lat - l.lat) / 2), 2) +-- + cos(radians(l.lat)) * cos(radians(ca.anchor_lat)) +-- * power(sin(radians(ca.anchor_lon - l.lon) / 2), 2) +-- )) > ca.radius_km +-- GROUP BY l.source, l.city +-- ORDER BY l.source, l.city; + +WITH city_anchor (city_name, anchor_lat, anchor_lon, radius_km) AS (VALUES + ('Нижний Тагил', 57.910::double precision, 59.980::double precision, 15.0::double precision), + ('Каменск-Уральский', 56.414::double precision, 61.918::double precision, 15.0::double precision), + ('Первоуральск', 56.908::double precision, 59.943::double precision, 15.0::double precision), + ('Верхняя Пышма', 56.976::double precision, 60.578::double precision, 8.0::double precision), + ('Серов', 59.604::double precision, 60.578::double precision, 15.0::double precision) +) +UPDATE listings l +SET city = NULL +FROM city_anchor ca +WHERE ca.city_name = l.city + AND l.source IN ('yandex', 'cian') + AND l.is_active = true + AND l.lat IS NOT NULL + AND l.lon IS NOT NULL + -- Адресный критерий (issue #2628 п.2/п.3) — если текст адреса называет + -- город метки, метка верна независимо от координат (защищает оба известных + -- false positive — Пышма/Орджоникидзе-1 и Первоуральск/Береговая-7А). + AND l.address NOT ILIKE '%' || ca.city_name || '%' + -- Гео-критерий — тот же haversine, что write-time guard в scraper_kit.base + -- (`_haversine_km`), anchors/радиусы из pipeline.py `CITY_ANCHORS` / + -- `_CITY_STAMP_RADIUS_KM`. + AND 2 * 6371.0 * asin(sqrt( + power(sin(radians(ca.anchor_lat - l.lat) / 2), 2) + + cos(radians(l.lat)) * cos(radians(ca.anchor_lat)) + * power(sin(radians(ca.anchor_lon - l.lon) / 2), 2) + )) > ca.radius_km; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/208_reenable_domclick_detail_backfill.sql b/tradein-mvp/backend/data/sql/208_reenable_domclick_detail_backfill.sql new file mode 100644 index 00000000..abd4b585 --- /dev/null +++ b/tradein-mvp/backend/data/sql/208_reenable_domclick_detail_backfill.sql @@ -0,0 +1,27 @@ +-- 208_reenable_domclick_detail_backfill.sql +-- Issue #2636, шаг 3 плана возврата: включить domclick_detail_backfill обратно. +-- +-- Была выключена миграцией 206 после 30 дней с нулевым выхлопом +-- (attempted=491, enriched=0, failed=431, blocked=60). Диагностика +-- (коммент в #2636, 2026-08-05) показала корень: detail-слой распознавал +-- QRATOR-блок 4 из 6 маркеров — блок-страницы уходили в failed вместо +-- blocked, block-breaker не срабатывал, батч прожигался по заблокированному +-- ресурсу. Починено в PR #2645 (единый DOMCLICK_BLOCK_MARKERS для +-- serp+detail + warning-лог непроматченного HTML), задеплоено и +-- ВЕРИФИЦИРОВАНО живым ручным прогоном 2026-08-05 (шаг 2 плана): +-- - card 2078185446 → ok:true, полное обогащение (repair_state, +-- year_built=2024, ЕГРН-площадь) — первый успешный live-фетч; +-- - card 2078446537 → DomClickBlockedError «challenge page detected, +-- markers checked: (7)» — challenge теперь распознаётся, breaker +-- остановит батч на max_consecutive_blocks=3 вместо прожига 100. +-- +-- Idempotency: повторный прогон — no-op (enabled уже true). + +BEGIN; + +UPDATE scrape_schedules + SET enabled = true + WHERE source = 'domclick_detail_backfill' + AND enabled = false; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/209_scrape_proxies_disabled_reason.sql b/tradein-mvp/backend/data/sql/209_scrape_proxies_disabled_reason.sql new file mode 100644 index 00000000..87332618 --- /dev/null +++ b/tradein-mvp/backend/data/sql/209_scrape_proxies_disabled_reason.sql @@ -0,0 +1,53 @@ +-- 209_scrape_proxies_disabled_reason.sql +-- scrape_proxies.disabled_reason — различить "оператор выключил руками" от +-- "пул выключил сам после серии сбоев" (#2610). +-- +-- WHY: +-- #2609 сделало mark_health(ok=True) безусловно ставить enabled=true — чинит +-- реальный прод-инцидент (авто-выключенный после серии сбоев узел иначе не +-- проверяется больше никогда, транзиентный сбой = вечный приговор). Побочка: +-- оператор снимает узел с ротации через PATCH /proxies/{id} (enabled=false) — +-- первая же успешная ipify-проба молча возвращает его обратно. Особенно больно, +-- когда узел забанен площадкой (Авито): ipify через него ходит чисто (ipify +-- никого не банит), значит узел гарантированно вернётся, и оператор снимает +-- его снова и снова. +-- +-- WHAT: +-- disabled_reason text, NULL по умолчанию. Семантика: +-- NULL — узел не выключен вручную (авто-disable по consecutive_fails — +-- тоже NULL здесь; поведение #2609 "авто-выключенный воскресает +-- первой успешной пробой" сохраняется без изменений). +-- текст — причина ручного выключения (по умолчанию из admin API — +-- "manually disabled via admin API", либо то, что передал оператор). +-- mark_health(ok=True) НЕ трогает enabled, пока это поле не NULL. +-- Сбрасывается в NULL ручным включением (PATCH .../{id} enabled=true). +-- +-- ВЫБОР text, а не boolean manually_disabled: единственное текстовое поле +-- расширяемо под будущую ТРЕТЬЮ причину выключения без новой миграции — #2600 +-- п.1 (сигнал бана площадкой до пула) заведёт значение вида 'banned:avito', +-- отличимое от 'manual' в логах/админке, и по той же самой колонке (не-NULL = +-- не воскрешать по голому ipify) без ALTER TABLE. Boolean такого не даёт — +-- потребовал бы либо второй колонки, либо потери "почему" при разборе. +-- +-- IDEMPOTENCY / SAFETY: +-- - BEGIN/COMMIT — атомарно. +-- - ADD COLUMN IF NOT EXISTS — повторный прогон no-op (auto-apply strict). +-- - Без DEFAULT — существующие строки получают NULL, т.е. "не выключены +-- руками" — обратная совместимость: ни одна прод-строка не станет ручной. +-- +-- Dependencies: 157_scrape_proxies.sql + +BEGIN; + +ALTER TABLE scrape_proxies + ADD COLUMN IF NOT EXISTS disabled_reason text; + +COMMENT ON COLUMN scrape_proxies.disabled_reason IS + 'NULL = не выключен вручную (mark_health(ok=True) может авто-восстановить ' + 'enabled=true, #2609). Текст = причина ручного выключения оператором ' + '(PATCH /proxies/{id} enabled=false, admin.py) — ipify-проба узел НЕ ' + 'воскрешает, пока поле не NULL. Сбрасывается в NULL ручным включением. ' + 'Задел под #2600 п.1 (сигнал бана площадкой до пула) — третья причина ' + 'ляжет сюда же без новой миграции, напр. значением вида ''banned:avito''.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/210_scrape_proxy_source_bans.sql b/tradein-mvp/backend/data/sql/210_scrape_proxy_source_bans.sql new file mode 100644 index 00000000..744f1ab7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/210_scrape_proxy_source_bans.sql @@ -0,0 +1,105 @@ +-- 210_scrape_proxy_source_bans.sql +-- Здоровье прокси по ПАРЕ «узел × источник» (#2600 п.2). +-- +-- WHY: +-- До сих пор бан был ГЛОБАЛЬНЫМ: #2600 п.1 (mark_banned) на распознанный бан +-- площадкой выключал узел целиком — enabled=false, disabled_reason='banned:'. +-- Реальность другая: Авито банит IP, а Яндекс через тот же IP ходит чисто. Один +-- забаненный источник выкидывал живой узел из пула для ВСЕХ источников, пул худел +-- в разы быстрее, чем его успевают пополнять (#2638). +-- +-- WHAT: +-- scrape_proxy_source_bans — по строке на пару (proxy_id, source). Пока +-- banned_until > now(), acquire(source) этот узел НЕ выдаёт; для любого ДРУГОГО +-- источника узел остаётся первосортным. Узел больше не выключается глобально — +-- enabled/disabled_reason остаются исключительно за оператором (#2610) и за +-- авто-disable'ом по серии транспортных сбоев (mark_health). +-- +-- ban_count — счётчик повторных банов той же пары: срок эскалирует +-- 6ч → 12ч → 24ч → 48ч → 72ч (потолок), см. SOURCE_BAN_BASE_HOURS/ +-- SOURCE_BAN_MAX_HOURS в app/services/proxy_pool.py. Истёкшие строки НЕ +-- удаляются сразу — purge в run_proxy_healthcheck сносит их только через 7 суток +-- после истечения (SOURCE_BAN_PURGE_DAYS), и это же механизм сброса ban_count: +-- узел, неделю чистый после снятия бана, начинает эскалацию с нуля. +-- +-- КОНВЕРСИЯ СТАРЫХ ГЛОБАЛЬНЫХ БАНОВ (обязательная часть миграции): +-- После #2600 п.1 на проде могли остаться узлы enabled=false с +-- disabled_reason LIKE 'banned:%'. Новый код такой семантики больше НЕ пишет и +-- ничего её не снимает, а mark_health(ok=True) не воскрешает узлы с non-NULL +-- disabled_reason (#2610) — узел завис бы выключенным навсегда, до ручного PATCH. +-- Поэтому здесь каждый такой узел конвертируется в per-source бан на 6 часов +-- (тот же SOURCE_BAN_BASE_HOURS) и возвращается в строй: enabled=true, +-- disabled_reason=NULL. Матчинг по ТОЧНОМУ списку 'banned:<источник>', а не по +-- LIKE — ручные тексты оператора (в т.ч. начинающиеся с 'banned:', этот формат +-- подсказан комментарием 209-й) НЕ трогаются, это его решение. +-- +-- IDEMPOTENCY / SAFETY: +-- - Весь файл в одной транзакции BEGIN/COMMIT. +-- - CREATE TABLE / INDEX IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING → +-- повторный прогон no-op (auto-apply strict на деплое это требует). +-- - Конверсионный UPDATE после повторного прогона не находит строк (первый +-- прогон уже снял disabled_reason) — тоже no-op. +-- - ON DELETE CASCADE: удаление прокси уносит его баны, «висячих» строк нет. +-- +-- Dependencies: 157_scrape_proxies.sql, 209_scrape_proxies_disabled_reason.sql + +BEGIN; + +CREATE TABLE IF NOT EXISTS scrape_proxy_source_bans ( + proxy_id bigint NOT NULL REFERENCES scrape_proxies(id) ON DELETE CASCADE, + source text NOT NULL, + banned_until timestamptz NOT NULL, + reason text, + ban_count integer NOT NULL DEFAULT 1, + banned_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (proxy_id, source) +); + +COMMENT ON TABLE scrape_proxy_source_bans IS + 'Баны прокси по паре (узел, источник), #2600 п.2. Активна строка с ' + 'banned_until > now() — acquire(source) такой узел не выдаёт, для других ' + 'источников узел остаётся доступным. Глобальное выключение узла (enabled=false) ' + 'сюда НЕ относится — это ручное действие оператора или авто-disable по серии ' + 'транспортных сбоев.'; + +COMMENT ON COLUMN scrape_proxy_source_bans.ban_count IS + 'Сколько раз эта пара банилась. Срок ТЕКУЩЕГО бана (banned_until - banned_at) = ' + 'base * 2^(ban_count-1), потолок SOURCE_BAN_MAX_HOURS: ban_count=1 → 6ч, 2 → 12ч, ' + '3 → 24ч и т.д. Сбрасывается удалением строки — либо purge''ем через ' + 'SOURCE_BAN_PURGE_DAYS после истечения, либо proxy_pool.clear_source_bans ' + '(ручное включение узла оператором / успешная ротация exit-IP).'; + +-- Горячий путь — NOT EXISTS-фильтр в acquire(): (proxy_id, source) уже покрыт PK, +-- этот индекс закрывает purge/листинг активных банов по времени. +CREATE INDEX IF NOT EXISTS idx_scrape_proxy_source_bans_until + ON scrape_proxy_source_bans (banned_until); + +-- ── конверсия старых глобальных банов (#2600 п.1 → п.2) ───────────────────── +-- +-- ТОЧНЫЙ список значений, а не LIKE 'banned:%': 209-я миграция сама предлагает этот +-- формат в комментарии, поэтому оператор мог написать руками что-то вроде +-- 'banned:avito вручную'. LIKE тогда дал бы source='avito вручную' (бан-строка, которая +-- ни с чем не сматчится) и МОЛЧА отменил бы ручное выключение. Домен ниже — тот же, что +-- у scrape_proxies.provider_affinity (на практике mark_banned п.1 писал только +-- avito/cian/yandex/domclick — это значения BrowserFetcher._source). +INSERT INTO scrape_proxy_source_bans (proxy_id, source, banned_until, reason) +SELECT id, + substring(disabled_reason from 8), -- отрезает префикс 'banned:' (7 символов) + now() + interval '6 hours', + 'migrated from disabled_reason (210)' +FROM scrape_proxies +WHERE NOT enabled + AND disabled_reason IN ('banned:avito', 'banned:cian', 'banned:yandex', + 'banned:domclick', 'banned:generic', 'banned:any') +ON CONFLICT (proxy_id, source) DO NOTHING; + +UPDATE scrape_proxies +SET enabled = true, + disabled_reason = NULL, + updated_at = now() +WHERE NOT enabled + AND disabled_reason IN ('banned:avito', 'banned:cian', 'banned:yandex', + 'banned:domclick', 'banned:generic', 'banned:any'); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql b/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql new file mode 100644 index 00000000..5144181f --- /dev/null +++ b/tradein-mvp/backend/data/sql/211_sales_vs_listings_segment_guard.sql @@ -0,0 +1,204 @@ +-- 211_sales_vs_listings_segment_guard.sql +-- Purpose: #2660 — «медианный торг» на витрине /sales-vs-listings считался с участием +-- ПЕРВИЧКИ. CTE window_listings в street_sales_vs_listings() (миграция 205) читает +-- listings без сегментного гарда #1186, поэтому в пару к ДКП-сделке вторички мог +-- встать лот застройщика: девелоперский прайс (фиксированный, без торга) формирует +-- показываемый пользователю процент скидки. +-- +-- Прод-замер (2026-08-05, БД tradein): +-- - популяция кандидатов на пейринг (окно period_months+6 = 30 мес, price_rub > 0): +-- 93 241 строк, из них novostroyki 25 428 (27.3%), vtorichka 66 256, +-- listing_segment IS NULL 1 557 (legacy до м.011 — считаем вторичкой); +-- - симуляция по 20 самым «густым» (улица, комнаты) ЕКБ (2 120 сделок): +-- до гарда 1 219 сделок получили listing-match, из них 299 (24.5%) — против +-- новостройки; медианный торг −18.18%. После гарда: 994 матча, медиана −17.11% +-- (+1.07 п.п. — торг перестаёт «утяжеляться» за счёт первички). +-- +-- Что НЕ меняется и почему: +-- - `is_active` в window_listings НЕ добавляем — осознанно (см. 067/205): функция +-- намеренно смотрит и снятые объявления, иначе к сделке нечего подставить +-- (объявление снимают ПОСЛЕ продажи — активные для пейринга бесполезны). +-- - Свежесть здесь тоже не при чём по той же причине: пейринг привязан к дате +-- сделки (window_days ± grace), а не к «сейчас». +-- - Сигнатура функции НЕ меняется (те же 7 аргументов, что после м.205). +-- CREATE OR REPLACE FUNCTION с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ +-- перегрузку вместо замены (грабли #2627, разобраны в шапке 205) — здесь +-- список типов побайтово тот же, значит замена in-place, оверлоада не будет. +-- DROP FUNCTION намеренно НЕ делаем: 205 уже дропнула старую 6-арг сигнатуру, +-- а дропать текущую 7-арг перед CREATE — лишний оконный простой для caller'а. +-- +-- Сегментный предикат — канонический #1186 (тот же, что в _COMMON_WHERE эстиматора +-- и в 109_asking_to_sold_ratio_segment_filter.sql): +-- (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') +-- NULL = legacy вторичка до миграции 011 (rosreestr/avito/yandex без сегмента), её +-- отбрасывать нельзя — потеряли бы 1 557 валидных кандидатов. +-- +-- ЗАВИСИМОСТИ: 205 (текущее тело + 7-арг сигнатура), listings.listing_segment (м.011). +-- Deploy order: после 210. Идемпотентность: CREATE OR REPLACE + COMMENT ON — оба +-- по конструкции re-run safe, ре-apply просто перезапишет то же тело. +-- Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется — сигнатура и +-- RETURNS TABLE те же. + +BEGIN; + +CREATE OR REPLACE FUNCTION street_sales_vs_listings( + p_street_pattern text, + p_area_m2 numeric, + p_rooms integer, + p_window_days integer DEFAULT 180, + p_area_tolerance numeric DEFAULT 0.15, + p_period_months integer DEFAULT 24, + p_target_city text DEFAULT NULL +) +RETURNS TABLE ( + deal_id bigint, + deal_date date, + deal_price_rub bigint, + deal_price_per_m2 integer, + deal_area_m2 numeric, + deal_rooms integer, + deal_floor integer, + deal_address text, + listing_id bigint, + listing_source text, + listing_source_url text, + listing_date date, + listing_price_rub bigint, + listing_price_per_m2 integer, + listing_area_m2 numeric, + days_listing_to_deal integer, + discount_pct numeric +) +LANGUAGE sql +STABLE +AS $$ + WITH window_deals AS ( + -- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city. + SELECT + d.id AS deal_id, + d.deal_date AS deal_date, + d.price_rub AS deal_price_rub, + d.price_per_m2 AS deal_price_per_m2, + d.area_m2 AS deal_area_m2, + d.rooms AS deal_rooms, + d.floor AS deal_floor, + d.address AS deal_address + FROM deals d + WHERE d.source = 'rosreestr' + AND d.address ILIKE p_street_pattern + AND d.rooms = p_rooms + AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND d.deal_date > NOW() - (p_period_months || ' months')::interval + AND d.price_rub > 0 + -- #2583 H4: deals.city заполнена на 100% — строгое равенство. + -- NULL p_target_city (город вне словаря) → фильтр не применяется. + AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city)) + ), + window_listings AS ( + -- Кандидаты-listings на той же улице, rooms exact, area ±tolerance, + -- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка. + SELECT + l.id AS listing_id, + l.source AS listing_source, + l.source_url AS listing_source_url, + l.listing_date AS listing_date, + l.price_rub AS listing_price_rub, + l.price_per_m2 AS listing_price_per_m2, + l.area_m2 AS listing_area_m2, + l.rooms AS listing_rooms, + COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date + FROM listings l + WHERE l.address ILIKE p_street_pattern + AND l.rooms = p_rooms + AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance) + AND p_area_m2 * (1.0 + p_area_tolerance) + AND l.price_rub > 0 + AND COALESCE(l.listing_date, l.scraped_at::date) + > NOW() - ((p_period_months + 6) || ' months')::interval + -- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%, + -- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим" + -- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий + -- фильтр выбросил бы почти все listings кроме avito. + AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)) + -- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя + -- подставлять лот застройщика — девелоперский прайс не торгуется и + -- уводит показываемый «медианный торг». Прод: 27.3% кандидатов — + -- первичка. NULL = legacy вторичка до м.011, оставляем. + AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka') + ), + paired AS ( + -- LEFT JOIN: сохраняем все сделки даже если нет listing match. + -- Для каждой сделки выбираем listing с listing_date ближайший + -- к deal_date (предпочтительно перед сделкой). + SELECT DISTINCT ON (wd.deal_id) + wd.deal_id, + wd.deal_date, + wd.deal_price_rub, + wd.deal_price_per_m2, + wd.deal_area_m2, + wd.deal_rooms, + wd.deal_floor, + wd.deal_address, + wl.listing_id, + wl.listing_source, + wl.listing_source_url, + wl.listing_date, + wl.listing_price_rub, + wl.listing_price_per_m2, + wl.listing_area_m2, + (wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal, + CASE + WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0 + THEN ROUND( + (wd.deal_price_rub - wl.listing_price_rub)::numeric + / wl.listing_price_rub * 100, + 2 + ) + ELSE NULL + END AS discount_pct + FROM window_deals wd + LEFT JOIN window_listings wl + ON wl.listing_event_date + BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date + AND (wd.deal_date + interval '30 days')::date + ORDER BY + wd.deal_id, + -- prefer listing event дата перед сделкой и ближе к ней + CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END, + CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END, + ABS((wd.deal_date - wl.listing_event_date)) + ) + SELECT + deal_id, + deal_date, + deal_price_rub, + deal_price_per_m2, + deal_area_m2, + deal_rooms, + deal_floor, + deal_address, + listing_id, + listing_source, + listing_source_url, + listing_date, + listing_price_rub, + listing_price_per_m2, + listing_area_m2, + days_listing_to_deal, + discount_pct + FROM paired + ORDER BY deal_date DESC; +$$; + +COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS + 'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, ' + 'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211). ' + 'Per-street matching: address ILIKE, area ±tolerance, rooms exact, window_days ' + 'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings ' + 'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или ' + 'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют ' + 'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. ' + 'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql new file mode 100644 index 00000000..07aaea2e --- /dev/null +++ b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql @@ -0,0 +1,68 @@ +-- 212_sber_index_pull_weekly.sql +-- sber_index_pull: такт 28 дней → 7. Ревью PR #2681 (#2674). +-- +-- ПОЧЕМУ. Монитор sber_freshness_monitor алертил при age > 60д +-- (sber_index_max_age_days 35 + lag_allowance 25). Прод-разбор всех 24 прогонов +-- монитора (read-only, 2026-08-06, scrape_runs.counters) показал ПИЛУ, а не застой: +-- +-- 13-16.07 alert=1 age 73,74,75,76 latest_month=5 (май) +-- 17.07 alert=0 age 46 latest_month=6 ← день загрузки +-- 18-31.07 alert=0 age 47..60 latest_month=6 +-- 01-05.08 alert=1 age 61..65 latest_month=6 +-- +-- Механика: загрузка ходила раз в 28 дней и приносила период на месяц новее, а +-- возраст считается от ПЕРВОГО ЧИСЛА покрытого месяца. Значит в момент самой +-- свежей загрузки возраст уже ~46 (07-17 минус 06-01), к следующей дорастает до +-- 46+28=74, и порог 60 лежит ВНУТРИ [46, 74] — тревога пересекала его каждый +-- цикл, 14 суток из 28. Девять срабатываний, поданных в #2674 как улика застоя +-- бенчмарка, — это замер НАШЕГО СОБСТВЕННОГО ТАКТА. После #2681 (WARNING → ERROR) +-- это стало бы ежедневным событием две недели в месяц, гаснущим само собой — +-- ровно та ложная тревога, которая приучает не читать алерты. +-- +-- ПОЧЕМУ ТАКТ, А НЕ ПОРОГ. Рассматривались два варианта: +-- (A) поднять lag_allowance 25 → 40 (порог 75 против потолка 74). Запас ОДИН +-- день: любой сдвиг окна/пропуск прогона на сутки — и ложная тревога +-- возвращается. Порог при этом продолжает кодировать наш такт, а не +-- поведение источника. Отклонено. +-- (B) ЭТА миграция: такт 28 → 7. Потолок возраста становится floor+7 ≈ 53 при +-- том же пороге 60 — запас 7 суток, т.е. один пропущенный недельный цикл +-- поглощается, два подряд дают тревогу (и это уже осмысленная тревога). +-- Порог 60 начинает означать ИМЕННО «Сбер перестал публиковать / загрузка +-- сломалась», а не «мы давно не ходили». +-- +-- ЦЕНА. pull_sber_indices делает SBER_REF_AREAS (3: 643/66/77) × SBER_DASHBOARDS +-- (3) = 9 GET-запросов к публичному неавторизованному sberindex.ru/api/sowa, без +-- пауз в цикле; прод-прогон 2026-07-17 занял 4 секунды (counters.duration_sec=4, +-- errors=0, upserted=639). Было 9 запросов / 28 дней, стало 9 / 7 дней = 36 в +-- месяц. Это тот же эндпоинт, который дёргают сами дашборды Сбера при каждом +-- открытии страницы; лимитов/бана на нём за всю историю прогонов не наблюдалось +-- (0 ошибок в 6 прогонах). Риск нагрузки считаем отсутствующим. +-- +-- ПОБОЧНО. Оценщик имеет СВОЙ per-estimate guard свежести с порогом +-- settings.sber_index_max_age_days=35. Он пробивается всегда, потому что возраст +-- стартует с ~46. Недельный такт сокращает НАШУ задержку обнаружения с ≤28 суток +-- до ≤7, то есть возраст = (лаг публикации Сбера) + ≤7 вместо + ≤28. Уйдёт ли он +-- под 35 — зависит от того, когда Сбер реально публикует месяц (по нашим данным +-- лаг публикации ≤46 и ≥31 суток, точнее по имеющимся прогонам не определить), +-- поэтому НЕ обещаем починку этого guard'а, только снятие нашей части задержки. +-- +-- next_run_at подтягиваем на ближайшее окно (05:00-06:00 UTC): без этого правка +-- default_params начнёт действовать только после уже запланированного прогона +-- 2026-08-14, а до тех пор ложная тревога продолжала бы идти каждый день. +-- LEAST() — чтобы повторное применение НИКОГДА не отодвигало прогон дальше. +-- +-- Идемпотентно: jsonb-конкатенация + LEAST, повторный прогон безопасен. +-- Кода не меняет: interval_days читается kit-планировщиком из default_params +-- (orchestration/scheduler.py::_defer_next_run_at, params.get("interval_days", 1)). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"interval_days": 7}'::jsonb, + next_run_at = LEAST( + next_run_at, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC' + ) +WHERE source = 'sber_index_pull'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql b/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql new file mode 100644 index 00000000..510db1ba --- /dev/null +++ b/tradein-mvp/backend/data/sql/213_listings_snapshots_status_vocab.sql @@ -0,0 +1,29 @@ +-- 213_listings_snapshots_status_vocab.sql +-- #2674 — словарь listings_snapshots.status стал трёхзначным: 'stale' ≠ 'closed'. +-- +-- ПРОБЛЕМА: комментарий колонки (016) обещал два значения — 'active' / 'closed' — +-- и при этом 'closed' не писал никто и никогда: status был константой 'active' у +-- всех 394 704 строк при 55 448 реально неактивных объявлениях. Писатель «снято» +-- появился в #2674, но одним значением обойтись нельзя: +-- - путь 404 (avito_detail_backfill) ЗНАЕТ, что объявления нет: площадка ответила; +-- - путь TTL (deactivate_stale_listings) знает только, что МЫ N суток не смотрели. +-- Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток +-- против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата +-- продажи» одной датой. Продукт про цены: смешивать факт с догадкой дорого. +-- +-- ДЕЛАЕТ: только обновляет COMMENT — сама колонка `text` без CHECK, DDL не нужен. +-- CHECK намеренно НЕ добавляем: 394 704 существующие строки валидны, а жёсткий +-- словарь на историческую таблицу — деструктивный риск ради нулевой выгоды. +-- +-- Idempotent: COMMENT ON COLUMN — безусловная перезапись, безопасно повторно. +-- Apply after: 212_sber_index_pull_weekly.sql + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.status IS + '''active'' = объявление видели в прогоне. ' + '''closed'' = площадка ответила 404 на поштучном обходе (жёсткий факт снятия). ' + '''stale'' = TTL-деактивация: мы N суток не смотрели (догадка, НЕ дата продажи). ' + 'NULL = неизвестно. Словарь расширен в #2674 — до него писалось только ''active''.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql new file mode 100644 index 00000000..5b8095e6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql @@ -0,0 +1,116 @@ +-- 214_drop_dead_run_metrics.sql +-- Purpose (#2674): удалить показатели, которые не могут быть ненулевыми. +-- +-- Один класс находок: колонка есть, админка её показывает, писателя нет ни одного — +-- и ноль читается как «всё чисто», а не как «мы это не считаем». Такой показатель +-- хуже отсутствующего: он создаёт ложную уверенность. Числа с прода 2026-08-06. +-- +-- 1. listings.is_outlier + v_data_quality.outliers_flagged +-- 93 408 строк listings, is_outlier = false у ВСЕХ, NULL у нуля — то есть +-- только DEFAULT из 002, ни одного UPDATE за всю историю. Писателя нет и не +-- задумывалось: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ +-- подборке аналогов (estimator._filter_outliers) и живёт ровно один запрос — +-- один и тот же лот выброс для одной оценки и нормальный аналог для соседней. +-- Persist-флаг на объявлении такое отношение выразить не может в принципе, +-- поэтому реализовать «пометку» нечем: это остаток отменённой затеи +-- («помечено модели после aggregation», 002_core_tables.sql), а не недоделка. +-- +-- 2. scrape_runs.run_type +-- 3244 прогона, ровно одно значение 'city_sweep' — DEFAULT из 051; ни одно +-- место кода run_type не задаёт. Колонка подписывала «city_sweep» прогоны, +-- которые никаким sweep не были: proxy_healthcheck (1630 шт.), +-- deactivate_stale_*, sber_index_pull, rosreestr_dkp_import. Что именно бежало, +-- честно называет source — второй, вырожденной оси не нужно. +-- +-- 3. scrape_runs.http_requests / http_errors / returning_count / disappeared_count +-- Ноль во всех 3244 прогонах с миграции 015, писателя нет вообще. +-- HTTP-запросы не считает никто ни в одном фетчере — заполнить нечем без +-- сквозной инструментации всех путей. Ошибки и «пропало/вернулось» УЖЕ +-- считает тот, кто их знает, и кладёт в counters jsonb: errors_count у +-- pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были +-- бы вторым, иначе определённым счётчиком того же слова. +-- +-- ⚠️ View-зависимость: v_data_quality (последний DDL — 095_dead_schema.sql) читает +-- listings.is_outlier и содержит CTE `SELECT * FROM listings`, что фиксирует +-- column-level зависимость на все колонки. Порядок тот же, что в 095: +-- DROP VIEW → DROP COLUMN → CREATE VIEW (уже без outliers_flagged). +-- +-- Dependencies: 002_core_tables.sql, 015_scrape_runs.sql, 051_scrape_runs_extend.sql, +-- 095_dead_schema.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP VIEW IF EXISTS / DROP COLUMN IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── 1. is_outlier: снять зависимый view, дропнуть колонку, пересоздать view ─── + +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS is_outlier; + +-- DDL идентичен 095, минус строка outliers_flagged (см. п.1 шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * FROM listings WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago, + (SELECT count(*) FROM v_price_divergence) AS price_disagreements_count; + +-- Комментарий из 095 утверждал, что view «refreshed on-demand by /api/v1/admin/ +-- data-quality endpoint». Это неправда с момента переписывания ручки: живой +-- /api/v1/admin/scraper/data-quality строит собственный запрос по listings/houses и +-- этого view не касается, читателей в коде нет ни одного (проверено #2674). PR, +-- тезис которого «ложный показатель хуже отсутствующего», не имеет права нести +-- ложное утверждение о читателе — пишем как есть. +COMMENT ON VIEW v_data_quality IS + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' + '#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только ' + 'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.'; + +-- ── 2-3. scrape_runs: вырожденный run_type + четыре счётчика без писателя ───── + +ALTER TABLE IF EXISTS scrape_runs + DROP COLUMN IF EXISTS run_type, + DROP COLUMN IF EXISTS http_requests, + DROP COLUMN IF EXISTS http_errors, + DROP COLUMN IF EXISTS returning_count, + DROP COLUMN IF EXISTS disappeared_count; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql new file mode 100644 index 00000000..03b78b11 --- /dev/null +++ b/tradein-mvp/backend/data/sql/215_avito_full_load_window_matches_cadence.sql @@ -0,0 +1,85 @@ +-- 215_avito_full_load_window_matches_cadence.sql +-- Окно ретроспективы avito_full_load приводится в согласие с тактом (#2674). +-- +-- КОНТЕКСТ. Каденс источника задаётся ключом default_params."interval_days" +-- (scheduler.compute_next_run_at), глубина обхода — ключом "incremental_days" +-- (run_avito_full_load: since = today - incremental_days, дальше пагинация +-- newest-first с ранней остановкой на первой странице без карточек >= since). +-- Это ДВА независимых литерала, которые обязаны совпадать — и они разъехались: +-- 129_avito_full_load_incremental_split.sql поставил incremental_days=2 при +-- тогдашнем ежедневном такте (2 >= 1 — окно перекрывало такт, дыр не было); +-- 206_scrape_schedules_cut_wasteful_load.sql перевёл источник на interval_days=7, +-- но incremental_days не тронул. +-- Итог на проде (scrape_schedules id=138, проверено read-only 2026-08-06): +-- {"interval_days": 7, "incremental_days": 2, ...} +-- Прогон видит listing_date в [D-2, D] = 3 календарных дня из 7; следующий прогон +-- начинает с [D+5, D+7]. Дни D+1..D+4 не попадают НИ в один прогон — 4 суток из 7 +-- (57%) структурно вне поля зрения источника. +-- +-- ЧИСЛА, обосновывающие новое значение (прод, tradein): +-- * Замер по ОДНОМУ прогону (run 2990, exhaustive 2026-08-02, 2317 строк +-- снапшота / 2184 с датой) — сколько лотов достаёт окно шириной W суток: +-- W=2 -> 147 W=6 -> 446 W=7 -> 1275 W=12 -> 1278 +-- Три вывода. Окно 2 теряет в 8.7 раза (147 против 1275). Шестёрка теряет +-- две трети семёрки (446 против 1275) — это обрыв, а не экономия. И 7..12 — +-- ПЛАТО: +3 лота на пять дополнительных суток окна, то есть семёрка стоит +-- на самой дешёвой точке плато, а не является компромиссом. +-- * Обрыв на 7 и плато за ним — свойство НЕ Avito, а КВАНТОВАНИЯ нашего же +-- парсера относительных дат (_parse_relative_date, providers/avito/serp.py): +-- «неделю назад» -> ровно today-7, «две недели назад» -> ровно today-14. +-- Возрасты 8..13 по этому пути недостижимы — на проде их 3 лота из 2184 +-- (это и есть плато). Значит бакет «возраст 7» — не «поднятые ровно неделю +-- назад», а ВСЁ, чему реально от 7 до 13 суток. W=6 режет не по пику +-- распределения, а по ГРАНИЦЕ КВАНТОВАНИЯ и теряет бакет целиком. +-- * Тот же бакет в общей выборке: (last_seen_at::date - listing_date) по +-- avito за 20 суток — возраст 0-2 = 594, возраст ровно 7 = 1212 из 3714 +-- датированных наблюдений (32.6%). +-- * Полосы соседних прогонов [D-7, D] и [D, D+7] смыкаются с суточным +-- перехлёстом, который покрывает дрейф расписания (замер: last_run +-- 2026-08-03 13:37 -> next_run_at 2026-08-10 14:16 = +7 суток 39 минут). +-- +-- ЦЕНА ПО ЗАПРОСАМ растёт НЕ пропорционально лотам. Стоимость бакета — +-- ceil(свежих / 50) страниц с полом в 1-2 страницы; при окне 7 на бакет +-- приходится ~15-20 свежих (1275 лотов на 77 бакетов = 7 комнатностей x 11 +-- ценовых seed-брекетов) — МЕНЬШЕ одной страницы. Большинство бакетов как +-- стояло на 1-2 страницах, так и останется, глубже пойдут только плотные. +-- Верхняя граница честная и продом уже пережитая: полный обход без отсечки +-- вообще — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990); окно 13-15 UTC +-- ограничивает только СТАРТ прогона, не длительность. +-- +-- ВОЗВРАЩАЕТЕ ЕЖЕДНЕВНЫЙ ТАКТ — ВЕРНИТЕ И ОКНО. Ни эта миграция (GREATEST +-- только расширяет), ни планировщик (расширяет до такта, не сужает) окно НЕ +-- сузят. interval_days 7 -> 1 при incremental_days = 7 даст ежедневный прогон +-- с семисуточной глубиной, то есть восьмикратный охват КАЖДЫЙ день. Такт и +-- окно менять одной правкой. +-- +-- Значение НЕ хардкодим числом 7, а выводим из фактического interval_days строки: +-- если такт когда-нибудь поменяют снова, повторный прогон файла (или ручной +-- вызов) снова сведёт окно с тактом. Ту же инвариантность страхует код — +-- scheduler._job_avito_full_load расширяет окно до такта и пишет warning. +-- GREATEST с текущим значением: миграция никогда не СУЖАЕТ окно, если оператор +-- выставил его шире такта осознанно. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), +-- 129_avito_full_load_incremental_split.sql (incremental_days), +-- 206_scrape_schedules_cut_wasteful_load.sql (interval_days=7). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет — idempotent by construction. +-- source сравнивается на ТОЧНОЕ равенство: 'avito_full_load_exhaustive' — соседняя +-- джоба, у неё incremental_days нет вовсе (полный обход) и трогать её нельзя. + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params + || jsonb_build_object( + 'incremental_days', + GREATEST( + COALESCE(CAST(default_params ->> 'interval_days' AS integer), 1), + COALESCE(CAST(default_params ->> 'incremental_days' AS integer), 1) + ) + ), + updated_at = NOW() +WHERE source = 'avito_full_load' + AND default_params ? 'incremental_days'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql b/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql new file mode 100644 index 00000000..4bacb319 --- /dev/null +++ b/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql @@ -0,0 +1,245 @@ +-- 216_dead_code_sweep.sql +-- Purpose (#2674, раздел «Мёртвый код»): развести три разных вещи, которые снаружи +-- выглядят одинаково — «написано и ни разу не сработало». +-- +-- 1. ОБОРВАННАЯ ПРОВОДКА — механизм рабочий, звать некому/нечем. Чиним подключением. +-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем. +-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня. +-- +-- Все числа — с прод-БД tradein 2026-08-06, ТОЧНЫМ count(*). Первая редакция несла +-- сюда reltuples-оценки планировщика (listings «142 569» против реальных 93 408 — +-- раздув мёртвыми кортежами на 53%); в постоянном комментарии к схеме оценке не место, +-- тем более в PR, тезис которого — «каждое утверждение несёт число с прода». +-- +-- ── 1. ПОДКЛЮЧАЕМ ─────────────────────────────────────────────────────────── +-- +-- A. domrf_kapremont_load — расписание для загрузчика ДОМ.РФ. +-- Загрузчик (app/services/domrf_kapremont_loader.py) и CLI +-- (app/tasks/domrf_kapremont_load.py) написаны и покрыты тестами с #2013, но +-- Handler'а в product_handlers и строки в scrape_schedules не существовало — +-- вызвать его было НЕЧЕМ. Итог на проде: 29 978 строк staging, у ВСЕХ один и тот +-- же loaded_at = 2026-07-12 13:19:30 (ровно один ручной запуск), 24 дня без +-- обновления. Это не мёртвый код: houses.year_built/material_walls/total_floors и +-- дальше listings.year_built (когортный фильтр эстиматора) кормятся именно отсюда. +-- +-- B. external_valuations.filters_hash — бэкфилл из сырых ответов. +-- Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ на УРОВЕНЬ +-- ВЫШЕ — estimation.sale.filtersHash (ключи sale на проде: isError, filtersHash, +-- data, isFetching). Колонка была пуста 0/1658, при том что в сырых ответах хеш +-- есть у 139/139 строк cian_valuation и все 139 значений различны. Путь починен в +-- providers/cian/valuation.py; здесь достаём то, что уже лежит в raw_payload. +-- +-- ── 2. УДАЛЯЕМ ────────────────────────────────────────────────────────────── +-- +-- C. asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds (мигр. 098, #928). +-- Ноль читателей и ноль писателей в коде — грепом не находится ни одного +-- упоминания вне самой 098 и манифеста. Флага tier_aware_ratio_enabled, под +-- который таблицы задумывались, в конфиге не существует. Посчитаны один раз при +-- накатке (21 + 5 строк, computed_at 2026-06-27) и с тех пор не двигались, тогда +-- как ЖИВАЯ asking_to_sold_ratios обновляется ежедневно (computed_at 2026-08-05). +-- То есть в БД лежат коэффициенты выкупа сорокадневной давности, которые выглядят +-- как рабочая сегментация — их достаточно один раз прочитать по ошибке, чтобы +-- получить оценку по устаревшему рынку. Методика не потеряна: derivation-CTE +-- целиком сохранён в 098, восстановить = переприменить файл. +-- +-- D. listings.merged_into — 93 408 строк, NULL у всех, ноль упоминаний в коде. +-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже +-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт +-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки, +-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у +-- всех 93 408), но её читает WHERE listings_search_mv (050/094), и снос колонки +-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша. +-- +-- E. house_sources.raw_payload + GIN-индекс по нему — 49 502 строки, NULL у всех. +-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493) +-- эту колонку в INSERT не включают; читателей нет, из публичного контракта +-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке, +-- которая всегда NULL, — чистая стоимость на каждой вставке. +-- Соседний house_sources.ext_url тоже пуст 49 502/49 502, но он ВХОДИТ в +-- market.v_house_sources — удаление сломало бы обещание стабильности контракта. +-- Оставляем и подписываем (см. п. 3). +-- +-- F. v_data_quality.price_disagreements_count — показатель, который не может быть +-- ненулевым. Считает строки v_price_divergence («разброс цен между площадками у +-- одного объявления > 5%»), а на проде у 89 699 объявлений РОВНО ОДИН источник +-- каждое: distinct listing_id = 89 699 при 89 699 строках listing_sources. +-- v_price_divergence = 0 строк, v_cross_source_health = 0 строк. Причина не в +-- данных: боевой путь загрузки (scrapers/base.py::_link_listing_to_house) зовёт +-- upsert_listing_source('source_link') напрямую и НЕ зовёт match_or_create_listing — +-- см. NOTE на matching/listings.py:188. Пока связывание источников не подключено, +-- ноль здесь читается как «расхождений нет», хотя честно это «мы не сравниваем». +-- Тот же довод, по которому 214 убрала outliers_flagged. +-- +-- ── 3. ОСТАВЛЯЕМ И ПОДПИСЫВАЕМ ────────────────────────────────────────────── +-- +-- G. Сами v_price_divergence / v_cross_source_health не удаляем: они выразимы и +-- станут ненулевыми в тот день, когда связывание источников заработает. Но в +-- COMMENT должно быть написано, что они пусты СТРУКТУРНО, а не по счастью. +-- Туда же — house_sources.ext_url. +-- +-- ⚠️ View-зависимость (тот же порядок, что 214): v_data_quality содержит +-- `WITH active_listings AS (SELECT * FROM listings)`, что фиксирует column-level +-- зависимость на ВСЕ колонки listings. Порядок: DROP VIEW → DROP COLUMN → +-- CREATE VIEW. Последний DDL v_data_quality — 214_drop_dead_run_metrics.sql. +-- +-- Dependencies: 028_matching_tables.sql, 029_extend_matching_valuation_dynamics.sql, +-- 046_views.sql, 052_scrape_schedules.sql, 098_asking_to_sold_ratios_tiered.sql, +-- 176_domrf_kapremont.sql, 214_drop_dead_run_metrics.sql. +-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего +-- 'domrf_kapremont_load' в product_handlers.build_product_handlers() — иначе +-- kit-scheduler не найдёт Handler на первом due-run. next_run_at = завтра, так что +-- даже при обратном порядке накатки окно не наступит раньше следующих суток. +-- Идемпотентно: ON CONFLICT DO NOTHING / DROP ... IF EXISTS / CREATE OR REPLACE VIEW / +-- бэкфилл под WHERE filters_hash IS NULL. + +BEGIN; + +-- ── A. Расписание загрузчика ДОМ.РФ (оборванная проводка) ──────────────────── +-- enabled = true: внешний источник, но открытые данные без auth и без анти-бота +-- (тот же класс, что sber_index_pull / rosreestr_quarter_poll). +-- interval_days = 7: реестр капремонта не меняется ежедневно, а прогон качает два +-- zip и парсит ~30 тыс. строк. Недельный такт достаточен и не жжёт трафик впустую. +-- Ключ читает compute_next_run_at из default_params (см. 129). +-- Окно 01:00-02:00 UTC. Первая редакция ставила 03:00-04:00 — ровно туда, где сидит +-- refresh_search_matview (сверено с прод-таблицей scrape_schedules), то есть именно +-- то задание, которое и переносит year_built в поиск. Планировщик берёт случайный +-- момент внутри окна и гоняет источники ПАРАЛЛЕЛЬНО, порядок он не гарантирует +-- ничем — совпадение окон превращало «сначала загрузка, потом обновление поиска» +-- в подбрасывание монеты. Час до 02:00 разводит их при типовой длительности прогона +-- и остаётся раньше rosreestr_dkp_import (04:00-06:00) и +-- asking_to_sold_ratio_refresh (06:00-07:00). +-- ЧЕСТНАЯ ОГОВОРКА: гарантии всё равно нет — при аномально долгом прогоне (сеть +-- ДОМ.РФ, ретраи) свежий year_built доедет до поиска на цикл позже. Ни блокировок, +-- ни потери данных: следующее обновление matview его подхватит. +-- Соседи в 01:00-02:00 — listing_source_snapshot и avito_city_sweep_kamensk_uralskiy; +-- общих ресурсов нет (ДОМ.РФ ходит своим httpx, мимо прокси-пула). +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'domrf_kapremont_load', + true, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"interval_days": 7}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +-- ── B. Бэкфилл filters_hash из уже сохранённых сырых ответов ───────────────── +-- Путь тот же, что теперь читает парсер. Пустую строку не пишем (NULLIF) — «есть +-- ключ, но он пуст» и «ключа нет» должны остаться одинаково NULL, а не разойтись. +UPDATE external_valuations +SET filters_hash = NULLIF(raw_payload #>> '{estimation,sale,filtersHash}', '') +WHERE filters_hash IS NULL + AND raw_payload #>> '{estimation,sale,filtersHash}' IS NOT NULL; + +-- ── C. Тиерные коэффициенты выкупа: две таблицы без читателя и писателя ────── +DROP TABLE IF EXISTS asking_to_sold_ratios_tiered; +DROP TABLE IF EXISTS asking_to_sold_tier_bounds; + +-- ── D+E+F. Колонки без писателя + показатель, который не может быть ненулевым ─ +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into; + +DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx; +ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload; + +-- DDL идентичен 214, минус строка price_disagreements_count (см. п. F шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * FROM listings WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago; + +COMMENT ON VIEW v_data_quality IS + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' + '#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один ' + 'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как ' + '«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен ' + 'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие.'; + +-- ── G. Подписи к тому, что оставлено как задел ─────────────────────────────── +COMMENT ON VIEW v_price_divergence IS + 'Объявления с разбросом цен между источниками > 5%. #2674: СЕГОДНЯ ВСЕГДА ПУСТ ' + 'и это структурно, а не случайно — боевой путь загрузки зовёт ' + 'upsert_listing_source(''source_link'') напрямую (scrapers/base.py::_link_listing_to_house) ' + 'и не зовёт match_or_create_listing, поэтому у каждого объявления ровно один ' + 'источник (89 699 строк listing_sources = 89 699 разных listing_id). View оставлен ' + 'как задел: станет осмысленным в тот день, когда связывание источников подключат ' + '(см. NOTE на matching/listings.py:188). Из v_data_quality исключён — там ноль ' + 'выглядел как результат проверки.'; + +COMMENT ON VIEW v_cross_source_health IS + 'Пер-объявленческая агрегация цен по источникам. #2674: пуст по той же причине, ' + 'что v_price_divergence — HAVING count(*) >= 2 недостижим, пока связывание ' + 'источников не подключено. Читателей в коде нет.'; + +COMMENT ON COLUMN house_sources.ext_url IS + '#2674: NULL у всех 49 502 строк — ни один из двух писателей house_sources ' + '(matching/houses.py, house_dedup_merge.py) эту колонку не заполняет. НЕ удалена ' + 'только потому, что входит в публичный контракт market.v_house_sources (мигр. 154), ' + 'где удаление колонки объявлено ломающим изменением. Соседний raw_payload из ' + 'контракта исключён и удалён этой же миграцией.'; + +COMMENT ON COLUMN houses.has_panorama IS + '#2674: заполняется из yandex_valuation (estimator._save_yandex_house_panorama). ' + 'До этой правки колонка была пуста у всех 9 366 домов, хотя парсер флаг разбирал. ' + 'Пишется ТОЛЬКО когда страница оценки подтверждённо отрисовалась (в мете есть год ' + 'или этажность) — иначе «метки нет» неотличимо от «страница не открылась», и NULL ' + 'честнее false.'; + +COMMENT ON COLUMN external_valuations.filters_hash IS + 'sha256 фильтров от самого Циана, estimation.sale.filtersHash (#2674: НЕ ' + 'estimation.sale.data.filtersHash — из-за лишнего уровня колонка была пуста 0/1658). ' + 'Отличается от cache_key: cache_key — наш хеш параметров ЗАПРОСА, filters_hash — ' + 'хеш того, во что Циан их разрешил, поэтому он способен схлопнуть варианты записи ' + 'одного адреса, которые cache_key разводит.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/217_position_in_serp_unexpressible.sql b/tradein-mvp/backend/data/sql/217_position_in_serp_unexpressible.sql new file mode 100644 index 00000000..950dfb13 --- /dev/null +++ b/tradein-mvp/backend/data/sql/217_position_in_serp_unexpressible.sql @@ -0,0 +1,62 @@ +-- 217_position_in_serp_unexpressible.sql +-- listings_snapshots.position_in_serp — диагноз «механизм невыразим», шаг 1 из 2. +-- +-- Dependencies: 016_listings_snapshots.sql (создала колонку). +-- Apply after: 216_dead_code_sweep.sql +-- Идемпотентно: только COMMENT ON COLUMN (CREATE OR REPLACE-семантика). +-- +-- ── ЧТО НАЙДЕНО ────────────────────────────────────────────────────────────── +-- upsert_listing_snapshot принимал position_in_serp, но НИ ОДИН боевой вызывающий +-- его не передавал: три call-site'а (scraper_kit/base.py:801 save_listings, +-- providers/cian/detail.py:382, app/tasks/avito_detail_backfill.py:454) плюс +-- ingest-скрипты — везде аргумент опущен. На проде колонка пуста 0 из 395 240 строк +-- (данные с 2026-05-30 по 2026-08-06). +-- +-- ── ПОЧЕМУ ЭТО НЕ «ПОДКЛЮЧИТЬ» ─────────────────────────────────────────────── +-- Соблазнительный вывод — «парсер выдачи знает индекс карточки, передайте его». +-- Он неверен: позиция есть свойство пары (объявление, конкретный прогон выдачи с +-- конкретными фильтрами), а выбранная структура этого отношения не выражает. +-- PRIMARY KEY (listing_id, snapshot_date) — максимум ОДНА строка на объявление в +-- сутки; run_id здесь обычный атрибут, да ещё и под COALESCE в ON CONFLICT. +-- Следствия на живых данных: +-- * 2026-08-05: 77 прогонов и 5392 total_seen дали 4595 строк снэпшотов; за +-- 2026-08-03 в таблице 13 разных run_id на одну дату. Четыре SERP-источника +-- (yandex/cian/avito/domclick city_sweep) в одни сутки пишут по одному ключу. +-- * Внутри одного city_sweep обход идёт по десяткам гео-якорей радиусом 1500 м с +-- перекрытием, и save_listings получает `anchor_lots` — 3 страницы ОДНОГО якоря, +-- а не общий ранжированный список. Одно объявление приезжает с разным индексом +-- от разных якорей того же прогона. +-- * Старый ON CONFLICT писал COALESCE(EXCLUDED.position_in_serp, <старое>) — в +-- строке оседал бы индекс последнего писателя дня. Это не «позиция в выдаче», а +-- произвольный представитель суток; хуже NULL, потому что читался бы как факт. +-- Чтобы позицию можно было хранить честно, нужна отдельная таблица с ключом +-- (run_id, listing_id) и сохранёнными фильтрами прогона. Такой задачи сейчас нет — +-- ни один потребитель позицию не читает (0 view/matview на проде ссылаются на +-- колонку), поэтому колонка удаляется, а не переносится. +-- +-- ── ПОЧЕМУ DROP НЕ ЗДЕСЬ ───────────────────────────────────────────────────── +-- Шаг 1 (этот файл + правка кода в том же PR): upsert_listing_snapshot перестаёт +-- упоминать колонку; колонка остаётся, комментарий объясняет почему. +-- Шаг 2 (отдельный PR, после того как образ с шагом 1 живёт на проде): +-- ALTER TABLE listings_snapshots DROP COLUMN IF EXISTS position_in_serp; +-- Порядок не косметический. deploy-tradein.yml применяет data/sql/*.sql ДО +-- перезапуска контейнеров («(3) Применяем SQL миграции — ДО app»), так что DROP в +-- одном деплое с правкой кода оставил бы окно в несколько минут, где ещё живой +-- СТАРЫЙ образ выполняет INSERT со списком колонок, включающим удалённую. Запись +-- снэпшотов fault-tolerant (SAVEPOINT + warning), поэтому упало бы тихо — ровно тот +-- класс потерь, который замечают через недели по дырке в истории цен. + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.position_in_serp IS + 'МЁРТВАЯ, удаляется следующей миграцией (#2674). Пуста 0/395240 на 2026-08-06: ' + 'писатель принимал аргумент, ни один вызывающий его не передавал. Не подключена, ' + 'потому что механизм невыразим в этой таблице: позиция — свойство пары ' + '(объявление, конкретный прогон выдачи с конкретными фильтрами), а PK здесь ' + '(listing_id, snapshot_date) — одна строка на объявление в сутки, при том что в ' + 'одни сутки по этому ключу пишут до 13 прогонов и 4 разных SERP-источника, а ' + 'внутри одного прогона объявление приходит с разным индексом от перекрывающихся ' + 'гео-якорей. Честное хранение требует таблицы с ключом (run_id, listing_id) и ' + 'сохранёнными фильтрами прогона; читателей у позиции нет ни одного.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql b/tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql new file mode 100644 index 00000000..689cee00 --- /dev/null +++ b/tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql @@ -0,0 +1,72 @@ +-- 218_scrape_runs_ban_kind.sql +-- scrape_runs.ban_kind — статус 'banned' перестаёт смешивать наш сбой с чужим (#2686). +-- +-- Dependencies: 015_scrape_runs.sql (создала таблицу и CHECK по status). +-- Apply after: 217_position_in_serp_unexpressible.sql +-- Идемпотентно: ADD COLUMN IF NOT EXISTS + DROP/ADD CONSTRAINT + UPDATE только там, +-- где ban_kind ещё NULL. +-- +-- ── ЧТО НАЙДЕНО ────────────────────────────────────────────────────────────── +-- Из 115 avito-прогонов со статусом 'banned' (замер на проде 2026-08-06): +-- 90 — «browser unavailable (proxy may be down)» — 503 НАШЕГО браузерного +-- сайдкара (05.07-03.08), площадка ни при чём; +-- 2 — прочие ошибки того же сайдкара (04.07, 15.07); +-- 10 — «Avito SERP firewall (browser-mode) … IP banned» — реальная блокировка +-- площадкой (16.06-06.08); +-- 11 — HTTP 429/403 (30.05-19.06); +-- 2 — прочее (21.06, 23.06). +-- То есть 92 из 115 (80%) прогонов, помеченных «нас забанили», — отказ нашей +-- собственной инфраструктуры. Оба исхода писали РАЗНЫЙ текст ошибки и получали +-- ОДИН статус: различитель лежал в данных и терялся ровно в момент присвоения. +-- +-- ── ЦЕНА, КОТОРАЯ УЖЕ УПЛАЧЕНА ─────────────────────────────────────────────── +-- Миграция 206 прочитала статус буквально — как «площадка распознаёт наш паттерн» — +-- и на этом основании замедлила avito_full_load_exhaustive более чем вдвое, переведя +-- на недельный такт. Основание было ложным. Возврат такта сюда НЕ входит: он вынесен +-- в #2687 на данные 9-10.08. +-- +-- ── ПОЧЕМУ КОЛОНКА, А НЕ НОВЫЙ СТАТУС ──────────────────────────────────────── +-- 1. У 'banned' есть побочная функция: в отличие от 'failed' он СОХРАНЯЕТ +-- done_buckets-чекпоинт пройденных бакетов. Она нужна обоим исходам — следующий +-- прогон не должен начинать с нуля ни при нашем отказе, ни при блокировке. +-- Оставив статус, получаем её даром. +-- 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых +-- молча даёт неверный ответ, если про него забыть: этот CHECK, IN-списки обоих +-- сторожей в orchestration/runs.py, Literal-фильтр admin API и хардкод-список +-- статусов во фронте (RunsTable.tsx). Это ровно тот класс оборванной проводки, +-- из-за которого задача и появилась. +-- 3. Прогон в обоих случаях требует одного обращения (оборвать, сохранить частичное); +-- различается только ДИАГНОЗ — метаданное, не состояние. +-- +-- В рантайме значение несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения +-- AvitoSidecarUnavailableError), а не разбирается из текста ошибки постфактум. +-- Разбор текста ниже — РАЗОВАЯ ретро-классификация уже накопленной истории; для +-- новых строк этот путь не используется. + +ALTER TABLE scrape_runs + ADD COLUMN IF NOT EXISTS ban_kind text; + +COMMENT ON COLUMN scrape_runs.ban_kind IS + 'Диагноз status=''banned'' (#2686): platform — площадка заблокировала ' + '(firewall/403/captcha); infra — не отдала НАША инфраструктура (браузерный ' + 'сайдкар/прокси). NULL для прогонов с другим статусом. Пишется из типа ' + 'исключения в момент отказа, не из текста ошибки.'; + +ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check; +ALTER TABLE scrape_runs + ADD CONSTRAINT scrape_runs_ban_kind_check + CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra')); + +-- Ретро-классификация истории (разово, только там, где ещё NULL). Порядок веток +-- важен: маркер сайдкара проверяется первым, потому что текст полного обхода +-- оборачивает его в свой префикс («avito full load aborted: avito SERP +-- browser-sidecar error (page=1): browser unavailable (proxy may be down)»). +UPDATE scrape_runs +SET ban_kind = CASE + WHEN error ILIKE '%browser-sidecar error%' + OR error ILIKE '%browser unavailable%' + OR error ILIKE '%proxy may be down%' THEN 'infra' + ELSE 'platform' + END +WHERE status = 'banned' + AND ban_kind IS NULL; diff --git a/tradein-mvp/backend/data/sql/219_deactivate_stale_health_gate.sql b/tradein-mvp/backend/data/sql/219_deactivate_stale_health_gate.sql new file mode 100644 index 00000000..4fbefecd --- /dev/null +++ b/tradein-mvp/backend/data/sql/219_deactivate_stale_health_gate.sql @@ -0,0 +1,67 @@ +-- 219_deactivate_stale_health_gate.sql +-- Пороги гейта здоровья сбора для TTL-деактивации (#2659). +-- +-- ЗАЧЕМ. TTL отвечает на вопрос «объявление сняли?», а меряет «мы его давно не +-- видели». Пока обход здоров, разница мала; когда обход лёг — разница равна всему +-- инвентарю. Прод, авито 10.07-26.07.2026: 17 суток подряд без единой собранной +-- страницы, TTL=10 снял 9 033 строки, из них 1 270 доказанно вернулись живыми, +-- как только сбор восстановился (сверка listing_source_snapshots с текущим +-- last_seen_at). Код гейта — app/tasks/deactivate_stale_avito.py. +-- +-- ПОЧЕМУ НЕ ПО СТАТУСУ ПРОГОНА. Ban-детектор эти провалы НЕ ловит: +-- yandex 18.07-30.07 — 5 прогонов в сутки, ВСЕ 'done', НОЛЬ 'banned', +-- total_seen = 0 тринадцать суток подряд; +-- domklik 20.07-30.07 — 11 суток 'done' с total_seen = 0, а 02.08 TTL=14 +-- снял 6 131 строку разом. +-- Поэтому здоровье меряется результатом (сколько строк источник реально подтвердил +-- свежими за 3 суток), а не статусом прогона. +-- +-- ОТКУДА ЧИСЛА. Ряд «подтверждений за 3 суток» по дням восстановлен из +-- listing_source_snapshots (снимок last_seen_at на каждую дату), срез совпадает +-- со срезом соответствующего UPDATE (source + segments + та же колонка свежести): +-- avito (все сегменты, last_seen_at): здоровые сутки 3542..6079, +-- провал 08.07-29.07 — 0..970 -> 1500 +-- yandex (vtorichka, last_seen_at): здоровые 897..2206, провал 0 -> 500 +-- cian (vtorichka, last_seen_at): 748..4329, провалов не было -> 500 +-- domklik (все сегменты, scraped_at): сейчас 62 за 3 суток, сбор +-- фактически стоит -> 200 +-- Каждый порог лежит между максимумом провала и минимумом здоровых суток: +-- авито 970 < 1500 < 3542 — исторический случай ловится с запасом в обе стороны. +-- +-- domklik ЗАБЛОКИРУЕТСЯ СРАЗУ, и это верный исход, а не сбой миграции: источник +-- подтверждает ~50 строк в сутки, TTL по нему уже один раз (02.08) снёс инвентарь +-- целиком. Пока сбор не восстановлен, деактивации там нечего подтверждать; протухшие +-- строки закрываются фильтром свежести на стороне чтения (#2656), а не TTL. +-- +-- Цена ошибки асимметрична: пропущенная деактивация чинится следующим прогоном, +-- ложная — только повторным сбором, которого может не быть. Пороги поэтому +-- смещены в сторону «пропустить прогон». +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), +-- 090/115/160 (сами расписания deactivate_stale_*). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять гейт вручную: min_confirmations = 0. + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('min_confirmations', 1500), + updated_at = NOW() +WHERE source = 'deactivate_stale_avito' + AND NOT default_params ? 'min_confirmations'; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('min_confirmations', 500), + updated_at = NOW() +WHERE source IN ('deactivate_stale_cian', 'deactivate_stale_yandex') + AND NOT default_params ? 'min_confirmations'; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('min_confirmations', 200), + updated_at = NOW() +WHERE source = 'deactivate_stale_domklik' + AND NOT default_params ? 'min_confirmations'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql b/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql new file mode 100644 index 00000000..ce7462b1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql @@ -0,0 +1,72 @@ +-- 220_listings_sale_type_domclick_dialect.sql +-- Issue #2674: listings.sale_type заполнялся тремя разными диалектами. +-- +-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, sale_type, count(*) ... GROUP BY 1,2): +-- avito : free 2976 · assignment 46 · NULL 45020 +-- cian : free 14800 · fz214 4673 · alternative 1327 · dupt 409 · +-- investment 64 · dzhsk 3 · pdkp 1 · NULL 463 +-- domklik : «Свободная продажа» 5546 · «Альтернатива» 553 · +-- «Первичная продажа» 2 · NULL 493 +-- n1 : NULL 382 · yandex: NULL 16721 (колонку не пишут вовсе) +-- Итого 6101 русская строка против 24 299 английских. +-- +-- ЧТО ЭТО ЛОМАЕТ: `WHERE sale_type = 'free'` не видит ни одной строки Домклика, +-- `GROUP BY sale_type` разносит одну и ту же «свободную продажу» на две корзины. +-- +-- ЧЕГО ЭТО НЕ ЛОМАЕТ СЕЙЧАС: читателей у колонки НОЛЬ. Grep по всему репозиторию +-- (2026-08-06) даёт только писателей (providers/{avito,cian,domclick}, base.py, +-- scripts/ingest_domclick_jsonl.py) и DDL — ни одного фильтра, агрегата, поля +-- схемы API или элемента фронтенда. Поэтому здесь НЕ вводится «канон» целиком: +-- выбирать между cian-токеном `dupt` и avito-токеном `assignment` (409 vs 46 +-- строк, оба = переуступка права требования) без единого читателя — монетка. +-- Чинится ровно то, что сломано объективно: один писатель клал сырую русскую +-- фразу там, где два других уже кладут английский токен. Остаток словарей +-- (dupt/assignment, первично-договорный хвост cian fz214/dzhsk/pdkp/investment) +-- задокументирован в COMMENT и ждёт первого настоящего читателя. +-- +-- ПРОВЕРКА СМЫСЛА (перед маппингом, требование #2674): это одна и та же ось — +-- структура сделки. cross-tab sale_type × listing_segment на проде: +-- domklik «Свободная продажа»/«Альтернатива» — 100% vtorichka; +-- cian free/alternative — 8545/1185 vtorichka (те же два полюса); +-- cian fz214/dupt/investment/dzhsk/pdkp — 5089 из 5107 строк novostroyki, +-- т.е. это ПОДмножество той же оси (какой договор на первичке), а не другой +-- признак. Конфликта смыслов нет → склейка free/alternative безопасна. +-- +-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06): +-- «Свободная продажа» → free : 5546 строк +-- «Альтернатива» → alternative : 553 строки +-- ИТОГО ожидаемо тронуто: 6099 строк. +-- «Первичная продажа» (2 строки) НЕ трогаем: у cian первичка кодируется типом +-- ДОГОВОРА (fz214/dzhsk/pdkp/dupt), маркетинговая метка Домклика договор не +-- называет — угадывать нельзя. Строки остаются как есть, видны в COMMENT. +-- +-- Писатель починен в том же PR: providers/domclick/detail.py canon_sale_type() +-- на границе save_detail_enrichment (её проходят все три производителя строк — +-- парсер карточки, ingest_domclick_jsonl.py, domclick_local_runner.py). +-- +-- Idempotent: повторный прогон — 0 строк (русских значений уже не осталось). + +BEGIN; + +UPDATE listings + SET sale_type = 'free' + WHERE source = 'domklik' + AND sale_type = 'Свободная продажа'; + +UPDATE listings + SET sale_type = 'alternative' + WHERE source = 'domklik' + AND sale_type = 'Альтернатива'; + +COMMENT ON COLUMN listings.sale_type IS + 'Структура сделки. Единого enum НЕТ — источники пишут свои словари: ' + 'cian — free/alternative/dupt/fz214/investment/dzhsk/pdkp (тип договора, ' + 'хвост fz214/dzhsk/pdkp/investment/dupt почти целиком novostroyki); ' + 'avito — free/alternative/auction/assignment (assignment = cian dupt, ' + 'токены НЕ сведены: #2674 не выбирал победителя, читателей у колонки нет); ' + 'domklik — free/alternative после миграции 220 (до неё русские фразы), плюс ' + '2 исторические строки «Первичная продажа» без соответствия в чужих словарях; ' + 'yandex и n1 колонку не пишут вовсе (100% NULL). Перед любым фильтром по ' + 'этой колонке сверьтесь с фактическим GROUP BY source, sale_type.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/221_backfill_house_suggestions_image_link.sql b/tradein-mvp/backend/data/sql/221_backfill_house_suggestions_image_link.sql new file mode 100644 index 00000000..698b7114 --- /dev/null +++ b/tradein-mvp/backend/data/sql/221_backfill_house_suggestions_image_link.sql @@ -0,0 +1,44 @@ +-- 221_backfill_house_suggestions_image_link.sql +-- Purpose (#2674, класс «площадка прислала, парсер разобрал, до базы не доехало»): +-- вернуть фотографии подсказок Avito IMV задним числом. +-- +-- Контекст. Колонка house_suggestions.image_link существует с миграции 064; до +-- PR #2682 (2026-08-06) парсер выбрасывал suggestions.items[].imageLink, а INSERT +-- её не перечислял. Правка #2682 чинит только БУДУЩИЕ строки. На проде на момент +-- этой миграции: 25 055 строк house_suggestions, image_link заполнен у 0 (count +-- точный, не reltuples). +-- +-- Почему исторические ВОССТАНОВИМЫ. Полный ответ /web/1/realty-imv/get-data +-- сохраняется целиком в house_imv_evaluations.raw_response (jsonb) — 2 685 строк, +-- у ВСЕХ 2 685 присутствует suggestions.items[0].imageLink. Ключ подсказки +-- (house_id, ext_item_id) однозначно соответствует (e.house_id, item->>'id'). +-- +-- Прод-замер ДО (SELECT-прогон этого же JOIN, 2026-08-06): +-- всего 25 055 · восстановимо 21 480 (85.7%) · не восстановимо 3 575 (14.3%). +-- 3 575 — это подсказки, чей house_id переоценивался позже: house_imv_evaluations +-- имеет UNIQUE(house_id) и ON CONFLICT DO UPDATE перетирает raw_response свежим +-- ответом, поэтому старый набор подсказок остаётся без своего сырья. Эти строки +-- невосстановимы в принципе — не «недоделанный бэкфилл», а утраченный источник. +-- +-- Идемпотентно: WHERE hs.image_link IS NULL — повторный прогон не трогает уже +-- заполненные строки и не перетирает то, что напишет живой скрейпер. +-- +-- Apply after: 216_dead_code_sweep.sql + +BEGIN; + +UPDATE house_suggestions hs +SET image_link = j.item ->> 'imageLink' +FROM house_imv_evaluations e +CROSS JOIN LATERAL jsonb_array_elements(e.raw_response -> 'suggestions' -> 'items') AS j(item) +WHERE e.house_id = hs.house_id + AND j.item ->> 'id' = hs.ext_item_id + AND hs.image_link IS NULL + AND nullif(j.item ->> 'imageLink', '') IS NOT NULL; + +COMMENT ON COLUMN house_suggestions.image_link IS + 'Ссылка на фото лота (suggestions.items[].imageLink). Пишется скрейпером с #2682; ' + 'исторические строки восстановлены из house_imv_evaluations.raw_response миграцией 221 ' + '(21 480 из 25 055; остальные потеряли сырьё при перезаписи оценки дома).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/223_scrape_runs_time_columns_meaning.sql b/tradein-mvp/backend/data/sql/223_scrape_runs_time_columns_meaning.sql new file mode 100644 index 00000000..b1759289 --- /dev/null +++ b/tradein-mvp/backend/data/sql/223_scrape_runs_time_columns_meaning.sql @@ -0,0 +1,71 @@ +-- 223_scrape_runs_time_columns_meaning.sql +-- Purpose (#2702): зафиксировать в схеме, что отметки времени прогона до этой +-- правки не охватывали его работу, и куда смотреть аналитике вместо разности. +-- +-- Dependencies: 015_scrape_runs.sql (создала started_at/finished_at/heartbeat_at), +-- 051_scrape_runs_extend.sql (finished_at/counters). +-- Apply after: 221_backfill_house_suggestions_image_link.sql +-- Идемпотентно: только COMMENT ON COLUMN (перезаписывает сам себя), данных не трогает. +-- +-- ── ЧТО БЫЛО СЛОМАНО ───────────────────────────────────────────────────────── +-- Финализаторы (mark_done / mark_failed / mark_banned) писали finished_at и +-- heartbeat_at через now(). В PostgreSQL now() == transaction_timestamp(): он +-- замерзает на СТАРТЕ транзакции. Финализатор выполняется той же сессией, что и +-- работа задачи; если рабочая транзакция всё это время оставалась открытой (задаче +-- нечего было коммитить — читающий батч, ноль сохранений, сохранение чужой сессией), +-- UPDATE финализатора попадал ВНУТРЬ неё и получал время НАЧАЛА работы. +-- +-- Прод-замер 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик +-- counters.duration_sec): +-- 153 — заявленная длительность больше окна finished_at − started_at в >1.5 раза; +-- 133 — окно меньше секунды при работе дольше 10 с. +-- 126 из этих 133 окон лежат в 9-64 мс: это не разброс, а подпись механизма — +-- столько проходит от коммита claim'а до первого запроса рабочей транзакции. +-- Крайние: прогон 346 (cian_history_backfill) — 18 230 с работы при окне 32 мс; +-- 497 (newbuilding_enrich) — 6 124 с при 21 мс; 341 (yandex_address_backfill) — +-- 1 460 с при 19 мс. +-- +-- Дефект был не сплошной: он зависел от того, коммитила ли задача перед финалом. +-- Средние окно/duration_sec по источникам на том же замере: +-- cian_history_backfill 2554 / 4222 ← окно короче работы +-- newbuilding_enrich 1302 / 2078 ← короче +-- yandex_address_backfill 107 / 1022 ← короче в 10 раз +-- avito_detail_backfill 2829 / 2016 ← длиннее (норма) +-- house_imv_backfill 1173 / 1173 ← совпадает +-- cadastral_geo_match 11 / 10 ← совпадает +-- +-- ── ПОЧЕМУ ИСТОРИЮ НЕ ЧИНИМ ────────────────────────────────────────────────── +-- Восстановить настоящий finished_at по строке нельзя: реальное время конца нигде +-- не сохранилось. Но counters.duration_sec измерялся монотонными часами процесса +-- (time.monotonic / time.time в самих задачах) и транзакцией не затронут — он у +-- этих строк верный. Поэтому история не переписывается, а помечается: аналитика +-- обязана брать длительность из счётчика, а не из разности отметок. +-- +-- Правка кода (clock_timestamp() вместо now() во всех финализаторах и в heartbeat) +-- живёт в app/services/scrape_runs.py + packages/scraper-kit/.../orchestration/runs.py. + +COMMENT ON COLUMN scrape_runs.started_at IS + 'Момент claim''а прогона. Пишется create_run своей транзакцией (commit сразу ' + 'после INSERT), поэтому откат рабочей транзакции задачи его не затрагивает. ' + 'С #2702 — clock_timestamp(); до него now() (= старт транзакции тика планировщика), ' + 'что давало сдвиг в пределах тика.'; + +COMMENT ON COLUMN scrape_runs.finished_at IS + 'Момент финализации прогона. ВНИМАНИЕ: у строк ДО #2702 (2026-08-06) значение ' + 'недостоверно — писалось now() (= transaction_timestamp) внутри рабочей транзакции ' + 'задачи, поэтому у прогонов, ничего не коммитивших по ходу работы, равно времени ' + 'её НАЧАЛА. На проде так вышло у 133 из 487 прогонов со счётчиком длительности ' + '(окно < 1 с при работе > 10 с). Длительность таких прогонов брать из ' + 'counters->>''duration_sec'' (монотонные часы процесса, транзакцией не затронуты), ' + 'а НЕ из finished_at − started_at.'; + +COMMENT ON COLUMN scrape_runs.heartbeat_at IS + 'Последний признак жизни прогона; на нём стоит поиск зависших (reap_zombies, порог ' + '6 ч). У строк ДО #2702 отставал от реальности на возраст открытой рабочей ' + 'транзакции по той же причине, что finished_at, — то есть критерий «завис» решал ' + 'по замороженной отметке. С #2702 пишется clock_timestamp().'; + +COMMENT ON COLUMN scrape_runs.counters IS + 'Счётчики прогона (jsonb). Ключ duration_sec, где он есть, измерен монотонными ' + 'часами процесса и остаётся единственным достоверным источником длительности для ' + 'строк до #2702 (см. комментарий к finished_at).'; diff --git a/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql new file mode 100644 index 00000000..b8b58859 --- /dev/null +++ b/tradein-mvp/backend/data/sql/224_houses_house_type_canon.sql @@ -0,0 +1,83 @@ +-- 224_houses_house_type_canon.sql +-- Issue #2678 (хвост #2675/#2674): twin миграции 141 для таблицы ДОМОВ. +-- +-- Миграция 141 привела camelCase-вокабуляр Циана к канону только в listings. +-- В houses он остался — и каждый читатель типа дома чинил его у себя (#2675 +-- починил домовую оценку; поштучный путь и подбор аналогов продолжали сравнивать +-- 'monolithBrick' с 'monolith_brick' и не совпадать). +-- +-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, house_type, count(*) FROM houses GROUP BY 1,2): +-- канон: brick 388+5+4+1 · panel 325+3+2+1 · monolith 118+37+6+1 · +-- block 90+1+1 · monolith_brick 2+1 · wood 2 +-- camelCase: monolithBrick 48 (derived) + 8 (cian_newbuilding) = 56 · +-- gasSilicateBlock 1 · aerocreteBlock 1 +-- прочее: stalin 3 · other 18 · wireframe 1 +-- NULL: 8440 из 8880 строк (тип дома вообще неизвестен — не наш случай) +-- +-- ЖИВОГО ПИСАТЕЛЯ camelCase В houses НЕТ: у всех 80 неканоничных строк +-- last_scraped_at = 2026-05-24 14:04:20.012209 — одна и та же метка, т.е. +-- единственный прогон backfill'а 063 (промоут типов из listings ДО миграции 141). +-- Единственный живой писатель houses.house_type — avito-каталог домов +-- (providers/avito/houses.py), он пишет русские подписи через свою карту; в том +-- же PR он переведён на общий normalize_house_type, чтобы неизвестное значение +-- шло как NULL, а не как 'other' (его единственный источник неканона). +-- +-- ПРОВЕРКА СМЫСЛА ПЕРЕД СКЛЕЙКОЙ (требование #2674 — не слепить разное): +-- контрольная группа в своих же данных. Для каждой неканоничной строки взяты +-- типы её ЖЕ объявлений (listings.house_id_fk), уже нормализованных 141: +-- monolithBrick 56 домов — monolith_brick присутствует у ВСЕХ 56 → одно и то же +-- stalin 3 дома — brick (совпадает с решением 141: «сталинка» = кирпич) +-- aerocreteBlock 1 дом — block +-- gasSilicateBlock 1 дом — block +-- other 18 домов — разброс monolith/monolith_brick/brick/block, т.е. +-- 'other' = «неизвестно», а не отдельный материал +-- wireframe 1 дом — wireframe и у объявлений (само-согласовано) +-- Вывод: склейка безопасна ТОЛЬКО для четырёх camelCase-токенов + stalin. +-- +-- ЧТО НАМЕРЕННО НЕ ТРОГАЕМ: +-- 'other' (18) и 'wireframe' (1) — честного соответствия в каноне нет +-- (см. #2675: normalize_house_type схлопывает их в None на чтении, и это +-- правильный ответ — NULL нейтрален для soft-penalty эстиматора, а выдуманный +-- материал был бы враньём). Стирать их здесь тоже не будем: это единственный +-- след того, что источник что-то про дом сказал. +-- +-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06): +-- monolithBrick -> monolith_brick : 56 строк +-- stalin -> brick : 3 строки +-- aerocreteBlock -> block : 1 строка +-- gasSilicateBlock -> block : 1 строка +-- foamConcreteBlock-> block : 0 строк (в houses не встречается, +-- оставлен для паритета с картой 141) +-- ИТОГО ожидаемо тронуто: 61 строка. +-- +-- Idempotent: WHERE перечисляет только мапимые токены → повторный прогон 0 строк. +-- Маппинг тождественен house_type_normalizer._RAW_TO_CANON и миграции 141 — +-- третьего словаря не заводим. + +BEGIN; + +UPDATE houses + SET house_type = CASE house_type + WHEN 'monolithBrick' THEN 'monolith_brick' + WHEN 'gasSilicateBlock' THEN 'block' + WHEN 'aerocreteBlock' THEN 'block' + WHEN 'foamConcreteBlock' THEN 'block' + WHEN 'stalin' THEN 'brick' + ELSE house_type + END + WHERE house_type IN ( + 'monolithBrick', 'gasSilicateBlock', 'aerocreteBlock', + 'foamConcreteBlock', 'stalin' + ); + +COMMENT ON COLUMN houses.house_type IS + 'Материал/тип дома, канон: panel/brick/monolith/monolith_brick/block/wood ' + '(тот же enum, что listings.house_type и scraper_kit.house_type_normalizer). ' + 'Писать сюда только через normalize_house_type — сырые вокабуляры источников ' + '(cian camelCase monolithBrick/gasSilicateBlock/stalin, yandex SCREAMING ' + 'MONOLIT_BRICK, русские подписи Авито) приводятся ДО записи, миграция 224 ' + 'вычистила исторические. Вне канона осталось намеренно: other (источник сказал ' + '«другое») и wireframe (каркас — материала в каноне нет). Неизвестный тип = ' + 'NULL, а не панель и не other: NULL нейтрален для soft-penalty эстиматора.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql b/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql new file mode 100644 index 00000000..8f9e2a8a --- /dev/null +++ b/tradein-mvp/backend/data/sql/227_drop_position_in_serp.sql @@ -0,0 +1,41 @@ +-- 227_drop_position_in_serp.sql +-- listings_snapshots.position_in_serp — DROP, шаг 2 из 2 (#2697, продолжение #2674). +-- +-- Dependencies: 217_position_in_serp_unexpressible.sql (диагноз + COMMENT на колонке). +-- Apply after: 224_houses_house_type_canon.sql +-- Идемпотентно: DROP COLUMN IF EXISTS. +-- +-- ── ПОЧЕМУ УДАЛЯЕМ, А НЕ ПОДКЛЮЧАЕМ ────────────────────────────────────────── +-- Полный разбор — в 217. Кратко: позиция есть свойство пары (объявление, конкретный +-- прогон выдачи с конкретными фильтрами), а PRIMARY KEY (listing_id, snapshot_date) +-- держит одну строку на объявление в сутки — при том что за 2026-08-03 по этому ключу +-- писали 13 разных run_id и четыре SERP-источника, а внутри одного city_sweep +-- объявление приезжает с разным индексом от перекрывающихся гео-якорей. Значение +-- оседало бы от последнего писателя дня и читалось бы как факт. Честное хранение — +-- отдельная таблица с ключом (run_id, listing_id) и сохранёнными фильтрами прогона, +-- то есть НЕ возврат этой колонки. +-- +-- ── ПРЕДУСЛОВИЕ ПРОВЕРЕНО ПЕРЕД МЕРЖЕМ (2026-08-06) ────────────────────────── +-- Окно «SQL применяется ДО перезапуска контейнеров» закрыто тем, что правка кода +-- (#2694) уже живёт на проде — проверено ПО КОДУ В КОНТЕЙНЕРАХ, не по зелёному +-- деплою: grep по /app в tradein-scraper и tradein-backend находит имя колонки +-- ровно в четырёх строках docstring'а snapshot_writer.py (18/34/37/76) и ни в одном +-- SQL; inspect.signature(upsert_listing_snapshot) колонку не содержит. +-- Оба живых писателя перечисляют колонки явно и этой в списке не имеют: +-- * scraper_kit/snapshot_writer.py::upsert_listing_snapshot (весь скрейп-путь), +-- * app/tasks/deactivate_stale_avito.py::_STALE_SNAPSHOT_TAIL (TTL-снимки 'stale'). +-- +-- ── ЧИТАТЕЛЕЙ НЕТ ──────────────────────────────────────────────────────────── +-- На проде: 0 непустых значений из 397 217 строк; 0 view/matview зависят от колонки +-- (pg_depend → pg_rewrite); индексов и триггеров на ней нет; foreign table над +-- listings_snapshots не существует (FDW-обёртки только над gendesign-таблицами); +-- в information_schema.columns имя встречается ровно в этой таблице. В коде: ни +-- одного SELECT-читателя (`SELECT *` по таблице нигде нет), фронт/экспортёры/админка +-- колонку не упоминают. Единственный сторож — tests/test_snapshot_writer.py, он +-- проверяет отсутствие колонки у писателя и после DROP остаётся валиден. + +BEGIN; + +ALTER TABLE listings_snapshots DROP COLUMN IF EXISTS position_in_serp; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d8d5a0f..0fb41dd0 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -175,6 +175,7 @@ 168_fdw_osm_poi_ekb.sql 169_osm_poi_ekb_local.sql 170_scrape_schedules_seed_osm_poi_ekb_refresh.sql +171_scrape_schedules_seed_geoportal_coords_backfill.sql 172_trade_in_leads.sql 173_scrape_proxies_add_domclick_affinity.sql 174_domclick_session_cookies.sql @@ -190,17 +191,42 @@ 184_user_events.sql 185_account_quota_overrides.sql 186_tg_support.sql +187_web_support_chat.sql +188_tg_support_chat_id_scope.sql +189_account_estimate_usage_nonnegative.sql +190_sale_share_price_bucket_signature.sql +191_account_quota_unlimited_flag.sql +192_tradein_users_auth.sql +193_tradein_users_seed.sql +194_deal_city_price_bands_tiers.sql +195_scrape_schedules_seed_deal_city_price_bands_refresh.sql +196_listings_city.sql +197_backfill_listings_city_from_url.sql +198_scrape_proxy_rotations.sql +199_scrape_proxies_asocks_rotate_url.sql +200_region_code_foreign_cities.sql +201_purge_dead_mobileproxy_proxies.sql +202_listing_source_snapshot_budget_sec.sql +203_purge_geocode_cache_house_letter.sql +204_cian_oblast_sweeps_secondary.sql +205_sales_vs_listings_city_filter.sql +206_scrape_schedules_cut_wasteful_load.sql +207_backfill_yandex_cian_city_geo_cleanup.sql +208_reenable_domclick_detail_backfill.sql +209_scrape_proxies_disabled_reason.sql +210_scrape_proxy_source_bans.sql +211_sales_vs_listings_segment_guard.sql +212_sber_index_pull_weekly.sql +213_listings_snapshots_status_vocab.sql +214_drop_dead_run_metrics.sql +215_avito_full_load_window_matches_cadence.sql +216_dead_code_sweep.sql # -# 187_web_support_chat.sql / 188_tg_support_chat_id_scope.sql — НАМЕРЕННО НЕ -# добавлены (2026-07-27, devops-аудит). Прецедент из ЭТОГО же репо: -# commit 5eadae1e (fix(tradein/support): address deep-review ... L5) добавил -# и тут же убрал "187_web_support_chat.sql" из этого файла с формулировкой -# "keeping an unmerged migration name out of it preserves the option to -# rename before merge without tripping the "can't rename applied -# migrations" test". Обе миграции — часть веб-чата поддержки (#2532/#2533), -# который на момент этой правки ещё активно дорабатывается в параллельной -# сессии/окне (тот же фиче-набор, соседняя задача). Дописывать их сюда сейчас -# повторило бы именно ту ошибку, которую L5 исправил: заморозить имя файла -# ДО того как он гарантированно осел на проде в финальном виде. Когда фича -# стабилизируется и подтверждено, что 187/188 применены (_schema_migrations -# на проде) — дописать одной строкой в отдельном PR. +# 2026-08-06: список догнан до факта прода. Проверка перед правкой — +# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было +# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено). +# Обратного расхождения нет: ни одной строки, которой не было бы на проде. +# +# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат +# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на +# проде в финальном виде. Они в _schema_migrations — условие выполнено. diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index cc1f1e49..29d7c22c 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396) "redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2) "pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py) + "bcrypt>=4.2.0", # password hashing для DB-auth (#2550) "playwright>=1.45", # Playwright client для connect к tradein-browser (#905) "scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов; # резолвится из workspace (см. [tool.uv.sources]), не с PyPI. diff --git a/tradein-mvp/backend/scripts/README.md b/tradein-mvp/backend/scripts/README.md index 85e6450e..941d84db 100644 --- a/tradein-mvp/backend/scripts/README.md +++ b/tradein-mvp/backend/scripts/README.md @@ -10,144 +10,20 @@ each row is wrapped in a SAVEPOINT (`db.begin_nested()`) per `.claude/rules/back --- -## Production usage (canonical) +## Address audit + backfill (issue #582) — REMOVED (#2593) -Scripts ship inside the `tradein-backend` image (PR F — `COPY scripts ./scripts` -в `backend/Dockerfile`). На VPS они уже в `/app/scripts/` — никаких manual -`docker cp` не нужно. - -`YANDEX_GEOCODER_API_KEY` подтягивается из `/opt/gendesign/tradein-mvp/backend/ -.env.runtime` через `env_file:` в `docker-compose.prod.yml` — никакого `-e` в -`docker exec` не нужно. - -```bash -# Backfill (forward geocode 4170 houses без coords) -ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --batch 2026-05-27_backfill' - -# Audit-only (reverse geocode проверка для уже geocoded houses) -ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --audit-only --batch 2026-05-27_audit' - -# Canary first -ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --limit 100 --batch canary_$(date +%F)' -``` - -После изменения `backend/.env.runtime` нужен `--force-recreate` контейнера -(см. `.claude/rules/deploy.md`): - -```bash -ssh gendesign 'cd /opt/gendesign/tradein-mvp && docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend' -``` - ---- - -## Address audit + backfill (issue #582) - -End-to-end address quality pipeline. Three scripts, two helpers, two SQL files. - -> Локальные примеры ниже — для dev-машины с `uv run` и переменными в shell. -> На prod используй canonical `docker exec` команды из секции выше — там -> `YANDEX_GEOCODER_API_KEY` уже подгружен из `backend/.env.runtime`. - -### `audit_address_mismatch.py` — Phase 1 baseline (PR #583) - -Stratified-sample audit (200 EKB houses) comparing `houses.address` vs -Yandex Geocoder reverse lookup. Writes one row per house into -`address_mismatch_audit` with the snapped point + canonical address + distance. - -```bash -DATABASE_URL=postgresql+psycopg://... \ -YANDEX_GEOCODER_API_KEY=... \ -uv run python -m scripts.audit_address_mismatch \ - --batch 2026-05-25_run1 \ - --limit-per-district 25 -``` - -Mode `auto` picks API if the key is set, otherwise Playwright (CAPTCHA-aware, -4-7s sleep between calls). API tier free is 25k req/day → 200-row sample -takes ~10s with no quota concern. - -Report: - -```bash -psql "$DATABASE_URL" -v batch='2026-05-25_run1' \ - -f scripts/address_audit_report.sql -``` - -### `backfill_house_coords.py` — Phase 2-3 (PR for #582) - -Two modes (`--audit-only` flag switches between them): - -**Backfill (default)** — forward-geocode `houses.address` for the ~4141 rows -WHERE `lat IS NULL OR lon IS NULL`. Only writes back if Yandex returns -`precision='exact'` or `'number'` (skips street-only / locality matches). -Each processed row gets an `address_mismatch_audit` entry with status -`backfill` / `imprecise` / `no_match` / `error`. - -```bash -DATABASE_URL=postgresql+psycopg://... \ -YANDEX_GEOCODER_API_KEY=... \ -uv run python -m scripts.backfill_house_coords \ - --batch 2026-05-27_backfill -``` - -Expected duration (~4141 rows, 50ms between calls, ~250ms RTT per request): -20-25 min. Expected output split (rough baseline from Phase 1 numbers): - -| Status | Approx rows | What it means | -|-------------|-------------|-----------------------------------------------------| -| `backfill` | ~3.3k–3.7k | UPDATE landed, lat/lon now populated | -| `imprecise` | ~300–500 | Match returned but precision too low — needs review | -| `no_match` | ~100–300 | Yandex couldn't resolve; address probably mangled | -| `error` | <50 | HTTP errors / timeouts — re-run picks them up | - -**Audit-only** — reverse-geocode the ~4452 houses WITH coords, write -audit rows with status `ok` (≤50m) / `mismatch` (>50m) / `no_match` / `error`. -Does NOT modify the `houses` table. - -```bash -uv run python -m scripts.backfill_house_coords \ - --batch 2026-05-27_audit --audit-only -``` - -Combined budget for both phases (~8.6k requests) is well under the 25k/day -Geocoder free tier. - -### Common ops - -Canary first — run with `--limit 100` and inspect the audit table before -letting the full job loose: - -```bash -uv run python -m scripts.backfill_house_coords \ - --batch canary_$(date +%F) --limit 100 -psql "$DATABASE_URL" -c " - SELECT audit_status, COUNT(*) - FROM address_mismatch_audit - WHERE audit_batch = 'canary_$(date +%F)' - GROUP BY audit_status; -" -``` - -Resume after crash / quota hit — same `--batch` label, the UNIQUE -`(house_id, audit_batch)` index skips finished rows: - -```bash -uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill -# ... interruption ... -uv run python -m scripts.backfill_house_coords --batch 2026-05-27_backfill -# logs: "resuming batch 2026-05-27_backfill: N rows already processed" -``` - -### Helpers (not entry points) - -- `_yandex_reverse.py` — `forward_via_api()`, `reverse_via_api()`, - `reverse_via_playwright()`, `YandexReverseResult` dataclass. Both API - paths share `_parse_api_payload` because Yandex's forward/reverse - envelopes have the same shape. -- `audit_address_sample.sql` — random sample for the Phase 1 audit (used - by `audit_address_mismatch.py`). -- `address_audit_report.sql` — psql-driven post-run summary (p50/p75/p95 - distance, top-20 outliers, per-district breakdown). +`audit_address_mismatch.py`, `backfill_house_coords.py`, `_yandex_reverse.py` +и их SQL-хелперы (`audit_address_sample.sql`, `address_audit_report.sql`) +удалены — весь pipeline опирался на Yandex Geocoder API, который выпилен +из проекта (#2593, части 1-3). `houses.address`→lat/lon geocoding теперь +идёт через `app/services/geocoder.py` (кадастр/геопортал ЕКБ-тиры + Nominatim +fallback, единственный живой внешний провайдер) на обычном write-path +(`/api/v1/trade-in/estimate`, listing ingest). Разовый forward-backfill +недостающих `houses` координат — `scripts/geocode_deals_nominatim.py` +(живой, работает с `rosreestr_deals`, не с `houses` — читай его docstring +перед использованием на других таблицах). Таблица `address_mismatch_audit` +осталась в схеме (используется `house_dedup_merge.py` при слиянии дублей +домов, независимо от Yandex-аудита). --- diff --git a/tradein-mvp/backend/scripts/_yandex_reverse.py b/tradein-mvp/backend/scripts/_yandex_reverse.py deleted file mode 100644 index 2e01146c..00000000 --- a/tradein-mvp/backend/scripts/_yandex_reverse.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Yandex Geocoder helpers for the address-mismatch audit + backfill (issue #582). - -Three geocoding paths exposed: - -- `reverse_via_api()` — Yandex Geocoder HTTP API, lon/lat → address. Fast, - structured response, needs a valid API key (env `YANDEX_GEOCODER_API_KEY`). - Free tier is 25k req/day, fine for ~8.5k houses + audit (~17k total). - -- `reverse_via_playwright()` — fallback when no API key is available. Drives - a real browser session at https://yandex.ru/maps/?…&mode=whatshere. Slower - and CAPTCHA-prone, so the driver inserts 4-7s sleeps between calls and we - raise a dedicated exception on CAPTCHA so the batch can pause-and-resume. - -- `forward_via_api()` — address → lon/lat + canonical address (Phase 2 of - issue #582). Used by `backfill_house_coords.py` to fill `houses.lat/lon` - for the 4141 houses scraped from sources that didn't include coords (esp. - yandex_valuation, which only returns an address string). - -All three return a `YandexReverseResult` dataclass — same shape regardless -of direction so the driver code stays implementation-agnostic. The `raw` -field always carries the full source payload for post-hoc diagnostics, and -`precision` / `kind` are filled in by the API paths so the caller can skip -imprecise matches (e.g. only-street-level results during backfill). - -Why three paths: -The user (issue #582 discussion) wants the audit to run on dev machines -that may not have an API key, but on prod we already provision the key for -estimator.py. Forward geocode is API-only — Playwright forward geocoding -through Yandex Maps search is too fragile (relevance ranking, suggest -dropdown). For dev without a key, backfill simply doesn't run. -""" - -from __future__ import annotations - -import asyncio -import logging -import random -from dataclasses import dataclass, field -from typing import Any - -import httpx - -logger = logging.getLogger(__name__) - -# Yandex Maps "what's here" URL — wraps a reverse-geocode in browser-driven UI. -# `whatshere[point]` accepts "," (note: lon first, Yandex convention). -_YANDEX_MAPS_WHATSHERE = ( - "https://yandex.ru/maps/?ll={lon:.6f}%2C{lat:.6f}&z=18&mode=whatshere" - "&whatshere%5Bpoint%5D={lon:.6f}%2C{lat:.6f}&whatshere%5Bzoom%5D=18" -) - -# Geocoder HTTP API. `kind=house` narrows the result to a building if possible, -# which is what we want for cadastr-style addresses (улица + дом). -_YANDEX_GEOCODE_API = "https://geocode-maps.yandex.ru/1.x/" - -# Reasonable timeouts: API call should be sub-second; we give it generous -# headroom for slow networks but not so much that a hang stalls the batch. -_API_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0) - - -# --------------------------------------------------------------------------- -# Dataclasses + exceptions -# --------------------------------------------------------------------------- - - -@dataclass -class YandexReverseResult: - """Normalized result of a geocode call (forward, reverse-API, or browser). - - Attributes: - address: Human-readable canonical address Yandex returned. For - reverse, this is the snapped address at the queried point. For - forward, this is the canonical form of the input address. None - if Yandex returned no match. - snapped_lat: Latitude of the matched object's geometric centre. - snapped_lon: Longitude of the matched object's geometric centre. - precision: For forward calls — Yandex match precision tag (`exact`, - `number`, `near`, `range`, `street`, `other`). For reverse — - same field is filled when present (usually `house` / `street`). - None for the playwright path. Used by the backfill driver to - skip imprecise matches. - kind: Object kind from Yandex (`house`, `street`, `locality`, ...). - Same source as `precision` — see metaDataProperty.GeocoderMetaData. - raw: Raw response payload retained for forensics (JSON dict from API, - or snapshot dict from playwright). Used to populate - `address_mismatch_audit.raw_payload` and - `houses.raw_payload.yandex_geocode`. - """ - - address: str | None - snapped_lat: float | None - snapped_lon: float | None - raw: dict[str, Any] = field(default_factory=dict) - precision: str | None = None - kind: str | None = None - - -class YandexBlockedError(RuntimeError): - """Raised when Yandex returns a CAPTCHA / anti-bot challenge. - - The driver catches this, marks the row `audit_status='blocked'`, logs the - current batch position, then exits cleanly so a human can intervene. - """ - - -# --------------------------------------------------------------------------- -# Path A — HTTP Geocoder API -# --------------------------------------------------------------------------- - - -async def reverse_via_api( - lat: float, - lon: float, - api_key: str, - *, - client: httpx.AsyncClient | None = None, -) -> YandexReverseResult: - """Reverse-geocode (lat, lon) via the Yandex Geocoder HTTP API. - - Why a separate `client` parameter: lets the driver reuse one - `AsyncClient` across all 200 calls (TCP keep-alive + connection pool), - and lets the tests inject a `MockTransport` to assert request shape. - - Args: - lat: latitude in WGS84. - lon: longitude in WGS84. - api_key: Yandex Geocoder API key. - client: optional pre-built async client. If None, a one-shot client - is created. - - Returns: - `YandexReverseResult` with the first `featureMember[0].GeoObject` - result, or all-None if Yandex returned no match (still includes - `raw` payload so we can later inspect why). - """ - params = { - "apikey": api_key, - # Yandex expects "lon,lat" (longitude first) per docs — same - # convention as the "whatshere" map URL above. - "geocode": f"{lon},{lat}", - "format": "json", - "kind": "house", - "results": "1", - } - - own_client = client is None - if client is None: - client = httpx.AsyncClient(timeout=_API_TIMEOUT) - - try: - resp = await client.get(_YANDEX_GEOCODE_API, params=params) - resp.raise_for_status() - data = resp.json() - finally: - if own_client: - await client.aclose() - - return _parse_api_payload(data) - - -def _parse_api_payload(data: dict[str, Any]) -> YandexReverseResult: - """Extract address + snapped point from a Yandex Geocoder API JSON response. - - Split out so unit tests can feed a fixture file directly without spinning - up an HTTP mock. Same payload shape for forward and reverse calls — - Yandex's response envelope is symmetric. - """ - try: - members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) - if not members: - return YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw=data) - - geo_obj = members[0].get("GeoObject", {}) - - # Address: prefer the long `metaDataProperty.GeocoderMetaData.text` - # (full canonical) and fall back to `name` (street + house number). - meta = geo_obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) - address = meta.get("text") or geo_obj.get("name") - precision = meta.get("precision") - kind = meta.get("kind") - - # Point format: " " — space-separated string. - point_str = geo_obj.get("Point", {}).get("pos", "") - snapped_lon: float | None - snapped_lat: float | None - if point_str: - try: - lon_s, lat_s = point_str.split() - snapped_lon = float(lon_s) - snapped_lat = float(lat_s) - except (ValueError, TypeError): - snapped_lon = None - snapped_lat = None - else: - snapped_lon = None - snapped_lat = None - - return YandexReverseResult( - address=address, - snapped_lat=snapped_lat, - snapped_lon=snapped_lon, - raw=data, - precision=precision, - kind=kind, - ) - except Exception as e: # pragma: no cover — defensive; tests cover happy paths - logger.warning("yandex API payload parse failed: %s", e) - return YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw=data) - - -# --------------------------------------------------------------------------- -# Path A.2 — Forward geocode (address → lon/lat) via HTTP API -# --------------------------------------------------------------------------- - - -async def forward_via_api( - address: str, - api_key: str, - *, - client: httpx.AsyncClient | None = None, -) -> YandexReverseResult: - """Forward-geocode an address string via the Yandex Geocoder HTTP API. - - Phase 2 of issue #582 — used by `backfill_house_coords.py` to populate - `houses.lat/lon` for houses that were scraped without coords (esp. - yandex_valuation rows, which only carry an address). - - Args: - address: free-form address ("ул Малышева 51", "Екатеринбург, Ленина 5", - etc.). Yandex's NLU is forgiving — no need to pre-normalize. - api_key: Yandex Geocoder API key. - client: optional pre-built async client. If None, a one-shot client - is created (matches `reverse_via_api` ergonomics). - - Returns: - `YandexReverseResult` with the canonical address + snapped point of - the first matching feature. `precision` and `kind` are populated so - the backfill driver can skip imprecise hits (e.g. precision='street' - means we landed on the road, not the building — too vague for - comparable-listings spatial queries). - - Same envelope as `reverse_via_api` — `_parse_api_payload` handles both. - """ - params = { - "apikey": api_key, - "geocode": address, - "format": "json", - # `kind=house` filters out street-only / locality-only matches at - # the API level when possible. Yandex still returns lower-precision - # results when no building matches, so the caller must double-check - # `precision` before writing to houses. - "kind": "house", - "results": "1", - # Locality bias for EKB — improves recall when the input address - # omits the city. The audit population is 99% EKB houses, so this - # is safe; non-EKB inputs (rare) still resolve, just with the bias. - "ll": "60.6122,56.8389", - "spn": "0.6,0.4", - } - - own_client = client is None - if client is None: - client = httpx.AsyncClient(timeout=_API_TIMEOUT) - - try: - resp = await client.get(_YANDEX_GEOCODE_API, params=params) - resp.raise_for_status() - data = resp.json() - finally: - if own_client: - await client.aclose() - - return _parse_api_payload(data) - - -# --------------------------------------------------------------------------- -# Path B — Playwright fallback -# --------------------------------------------------------------------------- - - -async def reverse_via_playwright( - lat: float, - lon: float, - page: Any, -) -> YandexReverseResult: - """Reverse-geocode (lat, lon) by driving yandex.ru/maps with Playwright. - - Why this exists: - The Yandex Geocoder API requires a key with paid quota for >25k/day. The - audit only needs 200 rows but a dev without a key still needs a way to - run the script, so we ship a browser-driven fallback. - - Implementation: - 1. Navigate to the `whatshere` URL — Yandex Maps responds by opening a - toponym card at the requested coordinates and rendering the resolved - address in the side panel. - 2. Wait for client hydration (`networkidle`). - 3. First try to read `window.__INITIAL_STATE__` — Yandex stores the - toponym address inside the hydrated Redux tree, which is more - stable across UI redesigns than DOM selectors. - 4. Fall back to DOM selectors (`.toponym-card-title-view__title` + - `__subtitle`) if the state walk doesn't find an address. - 5. Detect CAPTCHA (`.CheckboxCaptcha`) early and raise `YandexBlockedError` - so the batch can pause-and-resume without spamming Yandex. - - `page` is typed as `Any` to keep playwright a dev-only dep — runtime - importers don't need playwright installed if they only use the API path. - """ - url = _YANDEX_MAPS_WHATSHERE.format(lat=lat, lon=lon) - await page.goto(url, wait_until="domcontentloaded") - - # Light wait for client-side hydration. Yandex Maps fires lots of - # background XHRs so `networkidle` is too aggressive; this small wait is - # enough for the toponym card to render. - try: - await page.wait_for_load_state("networkidle", timeout=8000) - except Exception as e: - # Slow networks: continue — selectors will retry with their own waits. - logger.debug("networkidle wait timed out, continuing: %s", e) - await asyncio.sleep(random.uniform(0.5, 1.2)) - - # CAPTCHA gate — Yandex shows a `.CheckboxCaptcha` form when it suspects - # automation. Once we see it, every subsequent reverse call will also be - # blocked, so we raise immediately and let the driver stop the batch. - captcha = await page.query_selector(".CheckboxCaptcha") - if captcha is not None: - raise YandexBlockedError("Yandex CAPTCHA detected on maps page") - - # Attempt 1 — initial state walk. - state_addr: str | None = None - state_pos: tuple[float, float] | None = None - try: - state_addr, state_pos = await page.evaluate( - "() => {\n" - " const s = window.__INITIAL_STATE__ || {};\n" - " const card = (s.cards && s.cards.toponym) || (s.card && s.card.toponym) || null;\n" - " if (!card) return [null, null];\n" - " const addr = card.title || card.address || null;\n" - " const pos = card.coords || card.point || null;\n" - " if (pos && pos.length === 2) return [addr, [pos[0], pos[1]]];\n" - " return [addr, null];\n" - "}" - ) - except Exception as e: - logger.debug("playwright state walk failed (will fall back to DOM): %s", e) - - address = state_addr - - # Attempt 2 — DOM fallback. - if not address: - title_el = await page.query_selector(".toponym-card-title-view__title") - subtitle_el = await page.query_selector(".toponym-card-title-view__subtitle") - title = (await title_el.inner_text()).strip() if title_el else "" - subtitle = (await subtitle_el.inner_text()).strip() if subtitle_el else "" - # subtitle often holds "Екатеринбург, район", title the street + house - address = ", ".join([p for p in (subtitle, title) if p]) or None - - snapped_lat: float | None - snapped_lon: float | None - if state_pos: - # State stored as [lon, lat] in Yandex's coordinate convention. - snapped_lon = float(state_pos[0]) - snapped_lat = float(state_pos[1]) - else: - snapped_lon = None - snapped_lat = None - - raw = { - "url": url, - "state_addr": state_addr, - "state_pos": list(state_pos) if state_pos else None, - "dom_address": address if not state_addr else None, - } - - return YandexReverseResult( - address=address, - snapped_lat=snapped_lat, - snapped_lon=snapped_lon, - raw=raw, - ) diff --git a/tradein-mvp/backend/scripts/address_audit_report.sql b/tradein-mvp/backend/scripts/address_audit_report.sql deleted file mode 100644 index 7b391b9e..00000000 --- a/tradein-mvp/backend/scripts/address_audit_report.sql +++ /dev/null @@ -1,91 +0,0 @@ --- address_audit_report.sql --- Post-run report for the address-mismatch audit (issue #582 Phase 1). --- --- Sections: --- 1. Summary — count, p50/p75/p95/mean distance, % street_differs, --- % over 50m / 200m thresholds. --- 2. Top-20 outliers by distance (manual triage list). --- 3. Per-district breakdown — same metrics grouped by district column. --- --- Run via psql: --- psql "$DATABASE_URL" -v batch='2026-05-25_run1' -f scripts/address_audit_report.sql --- --- :batch is a psql client variable substituted via -v. - -\set ON_ERROR_STOP on - -\echo '==============================================' -\echo ' Address mismatch audit — batch:' :batch -\echo '==============================================' - --- --------------------------------------------------------------------------- --- 1) Top-level summary --- --------------------------------------------------------------------------- -\echo '' -\echo '--- Summary (status=ok rows only) ---' -SELECT - COUNT(*) AS n_total, - COUNT(*) FILTER (WHERE audit_status = 'ok') AS n_ok, - COUNT(*) FILTER (WHERE audit_status = 'no_match') AS n_no_match, - COUNT(*) FILTER (WHERE audit_status = 'error') AS n_error, - COUNT(*) FILTER (WHERE audit_status = 'blocked') AS n_blocked, - ROUND(percentile_cont(0.50) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p50_distance_m, - ROUND(percentile_cont(0.75) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p75_distance_m, - ROUND(percentile_cont(0.95) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p95_distance_m, - ROUND(AVG(distance_m)::numeric, 1) AS mean_distance_m, - ROUND(100.0 * AVG(CASE WHEN street_differs THEN 1.0 ELSE 0.0 END), 1) - AS pct_street_differs, - ROUND(100.0 * AVG(CASE WHEN distance_m > 50 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_50m, - ROUND(100.0 * AVG(CASE WHEN distance_m > 200 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_200m -FROM address_mismatch_audit -WHERE audit_batch = :'batch' - AND audit_status = 'ok'; - --- --------------------------------------------------------------------------- --- 2) Top-20 outliers --- --------------------------------------------------------------------------- -\echo '' -\echo '--- Top-20 outliers by distance ---' -SELECT - house_id, - district, - ROUND(distance_m::numeric, 1) AS distance_m, - street_differs, - LEFT(original_address, 60) AS original_address, - LEFT(snapped_address, 60) AS snapped_address -FROM address_mismatch_audit -WHERE audit_batch = :'batch' - AND audit_status = 'ok' - AND distance_m IS NOT NULL -ORDER BY distance_m DESC NULLS LAST -LIMIT 20; - --- --------------------------------------------------------------------------- --- 3) Per-district breakdown --- --------------------------------------------------------------------------- -\echo '' -\echo '--- Per-district breakdown (status=ok only) ---' -SELECT - COALESCE(district, '(no district)') AS district, - COUNT(*) AS n, - ROUND(percentile_cont(0.50) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p50_distance_m, - ROUND(percentile_cont(0.95) - WITHIN GROUP (ORDER BY distance_m)::numeric, 1) AS p95_distance_m, - ROUND(AVG(distance_m)::numeric, 1) AS mean_distance_m, - ROUND(100.0 * AVG(CASE WHEN street_differs THEN 1.0 ELSE 0.0 END), 1) - AS pct_street_differs, - ROUND(100.0 * AVG(CASE WHEN distance_m > 50 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_50m, - ROUND(100.0 * AVG(CASE WHEN distance_m > 200 THEN 1.0 ELSE 0.0 END), 1) - AS pct_over_200m -FROM address_mismatch_audit -WHERE audit_batch = :'batch' - AND audit_status = 'ok' -GROUP BY COALESCE(district, '(no district)') -ORDER BY n DESC, district; diff --git a/tradein-mvp/backend/scripts/audit_address_mismatch.py b/tradein-mvp/backend/scripts/audit_address_mismatch.py deleted file mode 100644 index 87dc7d88..00000000 --- a/tradein-mvp/backend/scripts/audit_address_mismatch.py +++ /dev/null @@ -1,595 +0,0 @@ -"""Audit driver — compares houses.address vs Yandex reverse geocode. - -Phase 1 of Forgejo issue #582. Pulls a stratified sample of EKB houses (25 -per admin district = 200 total), reverse-geocodes each via Yandex, computes -the distance between the stored coordinates and the snapped Yandex point, -and writes the result into `address_mismatch_audit`. - -Design choices: -- **Resumable**: the audit table has UNIQUE (house_id, audit_batch). Re-run - with the same `--batch` skips rows already inserted, so a partial run can - be picked up after CAPTCHA / network blip. -- **Mode auto**: prefer API when `YANDEX_GEOCODER_API_KEY` is set, fall back - to Playwright otherwise. Explicit override via `--mode {api,playwright}`. -- **No prod side effects**: the script only writes to one new audit table; - it never touches `houses`, `house_sources`, or any matching/listing row. -- **Per-row SAVEPOINT**: a single Yandex error must not nuke the entire - batch — wrap each INSERT in `db.begin_nested()` per backend.md. - -How to run: - DATABASE_URL=postgresql+psycopg://... \ - YANDEX_GEOCODER_API_KEY=... \ - python -m scripts.audit_address_mismatch --batch 2026-05-25_run1 - -Outputs (post-run): -- New rows in `address_mismatch_audit` with batch label. -- `scripts/address_audit_report.sql :batch=` for summary. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import logging -import os -import random -from dataclasses import dataclass -from datetime import date -from pathlib import Path -from typing import Any - -import httpx -from sqlalchemy import text -from sqlalchemy.orm import Session - -# Allow running both as `python -m scripts.audit_address_mismatch` (preferred) -# and as a stand-alone file (`python scripts/audit_address_mismatch.py`) -# without requiring package install. -try: - from app.core.db import SessionLocal # type: ignore[import-not-found] - from app.services.matching.normalize import normalize_address # type: ignore[import-not-found] -except ImportError: # pragma: no cover — fallback for adhoc invocation - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - from app.core.db import SessionLocal - from app.services.matching.normalize import normalize_address - -# `from .` works when run via -m; the absolute import works under pytest. -try: - from scripts._yandex_reverse import ( # type: ignore[import-not-found] - YandexBlockedError, - YandexReverseResult, - reverse_via_api, - reverse_via_playwright, - ) -except ImportError: - from _yandex_reverse import ( # type: ignore[no-redef] - YandexBlockedError, - YandexReverseResult, - reverse_via_api, - reverse_via_playwright, - ) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", -) -logger = logging.getLogger("audit_address_mismatch") - -# Playwright persistent context location — keeps cookies/local storage between -# runs so we look like a returning user, reducing CAPTCHA frequency. -_PLAYWRIGHT_USER_DATA = Path.home() / ".cache" / "tradein-audit-playwright" - -_PLAYWRIGHT_UA = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -) - -_SAMPLE_SQL_PATH = Path(__file__).parent / "audit_address_sample.sql" - - -# --------------------------------------------------------------------------- -# Domain helpers -# --------------------------------------------------------------------------- - - -@dataclass -class SampleRow: - """One house from the stratified sampling query.""" - - id: int - address: str - lat: float - lon: float - district: str | None - - -# Words that introduce a street rather than identify it. We skip these so the -# comparison lands on the actual street name ('малышева' / 'ленина'). Mirrors -# the canonical forms produced by `normalize_address` (which expands all known -# abbreviations to these full words). -_STREET_TYPE_WORDS = frozenset( - { - "улица", - "проспект", - "переулок", - "бульвар", - "проезд", - "шоссе", - "площадь", - "набережная", - "тупик", - "строение", - "корпус", - "дом", - } -) - -# Geographic prefix words that addresses sometimes carry before the street -# (e.g. 'россия екатеринбург улица малышева 51'). We skip them too so we -# converge on the same identifying token regardless of how verbose the -# source representation is. -_GEO_PREFIX_WORDS = frozenset( - { - "россия", - "свердловская", - "область", - "екатеринбург", - "город", - "г", - } -) - - -def _first_street_token(address: str | None) -> str | None: - """Extract the first street-name token of a normalized address. - - Phase-1 heuristic for "do the streets agree": skip numeric tokens (house - numbers), street-type words ('улица', 'проспект', …), and geographic - prefixes ('россия', 'екатеринбург', …) — the next token is the street - name itself, which is the identifying part we want to compare. - - Returns None for an empty address or when no candidate token remains. - """ - norm = normalize_address(address or "") - if not norm: - return None - for tok in norm.split(): - if not tok: - continue - # Skip purely numeric tokens (e.g. '5', '17а' if it starts with digit). - if tok[0].isdigit(): - continue - # Skip street type words and geographic prefixes. - if tok in _STREET_TYPE_WORDS or tok in _GEO_PREFIX_WORDS: - continue - return tok - return None - - -def _street_differs(original: str | None, snapped: str | None) -> bool | None: - """True iff first non-numeric token differs between the two addresses. - - Returns None when either side is empty — we cannot compute a meaningful - diff (caller writes NULL into the audit row). - """ - a = _first_street_token(original) - b = _first_street_token(snapped) - if a is None or b is None: - return None - return a != b - - -def _distance_meters( - db: Session, - olat: float, - olon: float, - slat: float, - slon: float, -) -> float | None: - """Compute great-circle distance via PostGIS geography type. - - We could do this in Python with a haversine formula, but the audit table - uses ST_Distance results elsewhere so we use the same authority to avoid - drift. ST_MakePoint(lon, lat) — PostGIS convention is lon first. - """ - row = db.execute( - text( - "SELECT ST_Distance(" - " ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), " - " CAST(:olat AS double precision)), 4326)::geography, " - " ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), " - " CAST(:slat AS double precision)), 4326)::geography" - ") AS m" - ), - {"olat": olat, "olon": olon, "slat": slat, "slon": slon}, - ).first() - if row is None or row[0] is None: - return None - return float(row[0]) - - -# --------------------------------------------------------------------------- -# Sampling + resumption queries -# --------------------------------------------------------------------------- - - -def _load_sample(db: Session, limit_per_district: int) -> list[SampleRow]: - """Run the stratified sampling SQL → list of SampleRow.""" - sql = _SAMPLE_SQL_PATH.read_text(encoding="utf-8") - rows = db.execute(text(sql), {"limit_per_district": limit_per_district}).mappings().all() - return [ - SampleRow( - id=r["id"], - address=r["address"], - lat=float(r["lat"]), - lon=float(r["lon"]), - district=r["district"], - ) - for r in rows - ] - - -def _already_processed_ids(db: Session, batch: str) -> set[int]: - """Return the set of house_id already in the audit table for this batch. - - Drives resumability: drop these from the sample before geocoding. - """ - rows = db.execute( - text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"), - {"b": batch}, - ).all() - return {r[0] for r in rows} - - -# --------------------------------------------------------------------------- -# Insert helper -# --------------------------------------------------------------------------- - - -def _insert_audit_row( - db: Session, - *, - house_id: int, - batch: str, - district: str | None, - original_address: str | None, - original_lat: float | None, - original_lon: float | None, - snapped_address: str | None, - snapped_lat: float | None, - snapped_lon: float | None, - distance_m: float | None, - street_differs: bool | None, - audit_status: str, - error_message: str | None, - raw_payload: dict[str, Any] | None, -) -> None: - """INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit. - - Wrapped in begin_nested by the caller per backend.md SAVEPOINT pattern. - """ - db.execute( - text( - "INSERT INTO address_mismatch_audit (" - " house_id, audit_batch, district," - " original_address, original_lat, original_lon," - " snapped_address, snapped_lat, snapped_lon," - " distance_m, street_differs," - " audit_status, error_message, raw_payload" - ") VALUES (" - " CAST(:house_id AS bigint), CAST(:batch AS text), :district," - " :original_address, :original_lat, :original_lon," - " :snapped_address, :snapped_lat, :snapped_lon," - " :distance_m, :street_differs," - " CAST(:audit_status AS text), :error_message," - " CAST(:raw_payload AS jsonb)" - ") ON CONFLICT (house_id, audit_batch) DO NOTHING" - ), - { - "house_id": house_id, - "batch": batch, - "district": district, - "original_address": original_address, - "original_lat": original_lat, - "original_lon": original_lon, - "snapped_address": snapped_address, - "snapped_lat": snapped_lat, - "snapped_lon": snapped_lon, - "distance_m": distance_m, - "street_differs": street_differs, - "audit_status": audit_status, - "error_message": error_message, - "raw_payload": json.dumps(raw_payload) if raw_payload is not None else None, - }, - ) - - -# --------------------------------------------------------------------------- -# Mode dispatcher -# --------------------------------------------------------------------------- - - -def _resolve_mode(mode: str, api_key: str | None) -> str: - """Translate `--mode auto` → concrete 'api' / 'playwright' choice. - - Explicit modes are passed through unchanged; auto chooses api iff a key - is configured (fail-fast: we don't want a "should have used the API but - silently fell back to slow scraping" surprise). - """ - if mode == "auto": - return "api" if api_key else "playwright" - return mode - - -# --------------------------------------------------------------------------- -# Main loop -# --------------------------------------------------------------------------- - - -async def _run_api_mode( - db: Session, - sample: list[SampleRow], - batch: str, - api_key: str, -) -> int: - """Geocode the sample using the HTTP Geocoder API.""" - processed = 0 - last_distance: float | None = None - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: - for i, row in enumerate(sample, start=1): - status = "ok" - err: str | None = None - res: YandexReverseResult | None = None - try: - res = await reverse_via_api(row.lat, row.lon, api_key, client=client) - except httpx.HTTPError as e: - status = "error" - err = f"http_error: {e!s}" - except Exception as e: # pragma: no cover — defensive - status = "error" - err = f"unhandled: {e!s}" - - distance = None - street_diff: bool | None = None - if res is not None and status == "ok": - if res.address is None: - status = "no_match" - else: - if res.snapped_lat is not None and res.snapped_lon is not None: - distance = _distance_meters( - db, row.lat, row.lon, res.snapped_lat, res.snapped_lon - ) - last_distance = distance - street_diff = _street_differs(row.address, res.address) - - try: - with db.begin_nested(): - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - district=row.district, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=distance, - street_differs=street_diff, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - # Per-row commit: each row is durable on disk before the next - # Yandex call; --batch resume picks up exactly where we crashed. - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("insert failed for house_id=%s: %s", row.id, e) - - if i % 10 == 0: - logger.info( - "progress %d/%d, mode=api, last_distance=%s", - i, - len(sample), - f"{last_distance:.1f}m" if last_distance is not None else "n/a", - ) - - return processed - - -async def _run_playwright_mode( - db: Session, - sample: list[SampleRow], - batch: str, -) -> int: - """Geocode via a persistent Playwright context (CAPTCHA-aware).""" - try: - from playwright.async_api import async_playwright # type: ignore[import-not-found] - except ImportError as e: - raise RuntimeError( - "Playwright is required for --mode playwright. " - "Install with `uv sync --group dev` and `playwright install chromium`." - ) from e - - _PLAYWRIGHT_USER_DATA.mkdir(parents=True, exist_ok=True) - processed = 0 - last_distance: float | None = None - - async with async_playwright() as p: - context = await p.chromium.launch_persistent_context( - user_data_dir=str(_PLAYWRIGHT_USER_DATA), - headless=False, - user_agent=_PLAYWRIGHT_UA, - locale="ru-RU", - timezone_id="Asia/Yekaterinburg", - ) - page = await context.new_page() - - try: - for i, row in enumerate(sample, start=1): - status = "ok" - err: str | None = None - res: YandexReverseResult | None = None - stop_batch = False - try: - res = await reverse_via_playwright(row.lat, row.lon, page) - except YandexBlockedError as e: - status = "blocked" - err = str(e) - stop_batch = True - except Exception as e: - status = "error" - err = f"playwright: {e!s}" - - distance = None - street_diff: bool | None = None - if res is not None and status == "ok": - if res.address is None: - status = "no_match" - else: - if res.snapped_lat is not None and res.snapped_lon is not None: - distance = _distance_meters( - db, row.lat, row.lon, res.snapped_lat, res.snapped_lon - ) - last_distance = distance - street_diff = _street_differs(row.address, res.address) - - try: - with db.begin_nested(): - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - district=row.district, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=distance, - street_differs=street_diff, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("insert failed for house_id=%s: %s", row.id, e) - - if stop_batch: - logger.error( - "Yandex CAPTCHA detected at position %d/%d (house_id=%s). " - "Stopping batch — re-run with same --batch to resume.", - i, - len(sample), - row.id, - ) - break - - if i % 10 == 0: - logger.info( - "progress %d/%d, mode=playwright, last_distance=%s", - i, - len(sample), - f"{last_distance:.1f}m" if last_distance is not None else "n/a", - ) - - # Random delay 4-7s between requests — keeps us under Yandex's - # heuristic rate limit while still finishing 200 rows in <30min. - # Skip the wait on the last iteration (no next request to space). - if i < len(sample): - await asyncio.sleep(random.uniform(4.0, 7.0)) - finally: - await context.close() - - return processed - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """argparse setup, factored out for testability.""" - p = argparse.ArgumentParser( - description="Phase 1 audit — houses.address vs Yandex reverse geocode.", - ) - p.add_argument( - "--batch", - default=f"{date.today().isoformat()}_run1", - help="Audit batch label. Same batch re-run skips already-processed houses.", - ) - p.add_argument( - "--limit-per-district", - type=int, - default=25, - help="Houses to sample per district (default 25 → ~200 total for EKB).", - ) - p.add_argument( - "--mode", - choices=("auto", "api", "playwright"), - default="auto", - help="auto = API if YANDEX_GEOCODER_API_KEY set, else playwright.", - ) - return p.parse_args(argv) - - -async def main(argv: list[str] | None = None) -> int: - """CLI entry point. Returns the number of rows processed this run.""" - args = _parse_args(argv) - api_key = os.environ.get("YANDEX_GEOCODER_API_KEY") - mode = _resolve_mode(args.mode, api_key) - - if mode == "api" and not api_key: - raise SystemExit("mode=api requested but YANDEX_GEOCODER_API_KEY is not set") - - logger.info( - "starting audit batch=%s mode=%s limit_per_district=%d", - args.batch, - mode, - args.limit_per_district, - ) - - db = SessionLocal() - try: - sample = _load_sample(db, args.limit_per_district) - logger.info("loaded sample: %d houses", len(sample)) - - # Resume support — drop already-processed house_ids. - done = _already_processed_ids(db, args.batch) - if done: - logger.info( - "resuming batch %s: %d rows already processed, %d remaining", - args.batch, - len(done), - len(sample) - sum(1 for s in sample if s.id in done), - ) - remaining = [s for s in sample if s.id not in done] - - if not remaining: - logger.info("nothing to do — batch %s is complete", args.batch) - return 0 - - if mode == "api": - n = await _run_api_mode(db, remaining, args.batch, api_key or "") - else: - n = await _run_playwright_mode(db, remaining, args.batch) - - logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode) - return n - finally: - db.close() - - -if __name__ == "__main__": # pragma: no cover - asyncio.run(main()) diff --git a/tradein-mvp/backend/scripts/audit_address_sample.sql b/tradein-mvp/backend/scripts/audit_address_sample.sql deleted file mode 100644 index 11e83b62..00000000 --- a/tradein-mvp/backend/scripts/audit_address_sample.sql +++ /dev/null @@ -1,47 +0,0 @@ --- audit_address_sample.sql --- Random sample of EKB houses for the address-mismatch audit (issue #582). --- --- Strategy: --- 1. Filter to houses with non-null lat/lon and non-empty address. --- 2. Random shuffle via `ORDER BY random()` — repeatable enough for spot --- sampling without needing a stable PRNG seed (the audit table dedupes --- via UNIQUE (house_id, audit_batch), so re-running gives idempotent --- results regardless of which rows land in the sample first). --- 3. Cap the result at :limit_per_district * 8 rows — keeps the bind-param --- contract compatible with the old stratified sampler (`:limit_per_district` --- is still honored, just multiplied by the assumed 8-district count). --- --- Why no spatial stratification anymore: --- The previous version JOINed to `gendesign_ekb_districts_geom` (FDW --- polygon table) to bucket houses by admin district. That join is fine on --- prod where FDW is wired, but it adds a dependency we don't need for --- Phase 2-3 (backfill + canonical reverse). Aggregation by district at --- report time still works — we re-derive district during the audit via --- spatial containment in the report SQL when needed. --- --- Bind param: --- :limit_per_district — kept for back-compat with the audit driver. --- Effective sample size = :limit_per_district * 8 (e.g. 25 → 200). --- --- Columns returned: --- id, address, lat, lon, district --- `district` is always NULL here — the audit driver will reverse-derive it --- from Yandex Geocoder response (Yandex returns admin component) or leave --- it NULL if not present in the response. --- --- NB: uses CAST(:x AS int) per project sql.md rule (psycopg v3 ignores ::type --- after bind params). - -SELECT - h.id, - h.address, - h.lat, - h.lon, - NULL::text AS district -FROM houses h -WHERE h.lat IS NOT NULL - AND h.lon IS NOT NULL - AND h.address IS NOT NULL - AND length(trim(h.address)) > 0 -ORDER BY random() -LIMIT CAST(:limit_per_district AS int) * 8; diff --git a/tradein-mvp/backend/scripts/backfill_house_coords.py b/tradein-mvp/backend/scripts/backfill_house_coords.py deleted file mode 100644 index 7a607730..00000000 --- a/tradein-mvp/backend/scripts/backfill_house_coords.py +++ /dev/null @@ -1,619 +0,0 @@ -"""Forward-geocode houses through Yandex Geocoder API to backfill lat/lon -and canonical address, plus optional reverse audit of already-geocoded houses. - -Phase 2-3 of Forgejo issue #582. Two modes (mutually exclusive): - - 1. Backfill (default) — for the ~4141 rows WHERE lat IS NULL OR lon IS NULL: - forward-geocode `houses.address` → snap to a Yandex `house`-precision - point, UPDATE houses with the new lat/lon + canonical address payload, - and write an `address_mismatch_audit` row with `audit_status='backfill'`. - - 2. Audit-only (--audit-only) — for the ~4452 rows that already have coords: - reverse-geocode (lat, lon) → snapped point + canonical address, compute - ST_Distance vs stored coords, write an `address_mismatch_audit` row with - status 'ok' (≤50m) or 'mismatch' (>50m). Does NOT touch houses. - -Design choices: -- **Per-row SAVEPOINT** (`db.begin_nested()`): a single Yandex/PostGIS error - must not nuke the entire batch. Per backend.md, never use bare rollback - inside a loop. -- **Resumable** via UNIQUE (house_id, audit_batch). Re-running the same - --batch label skips already-processed houses, so a partial run can be - picked up after CAPTCHA / network blip / 25k/day quota hit. -- **Precision filter**: backfill skips matches with precision in - ('street', 'other', 'range', 'near', None) — those are too imprecise for - comparable-listing spatial queries and would silently degrade matching - recall. The audit row still records what Yandex returned for forensics. -- **Rate limit**: 50ms between calls (~20 req/sec, well under Yandex's - 25 req/sec service limit). Backfill mode runs single-threaded. -- **Daily quota**: 4141 backfill + 4452 audit ≈ 8.6k requests. Free Geocoder - tier is 25k/day → comfortable buffer for retries. - -Usage: - YANDEX_GEOCODER_API_KEY=xxx \\ - DATABASE_URL=postgresql+psycopg://... \\ - python -m scripts.backfill_house_coords --batch 2026-05-27_backfill - - # Audit-only on the 4452 already-geocoded houses - python -m scripts.backfill_house_coords --batch 2026-05-27_audit \\ - --audit-only --limit 500 - -Outputs: -- Backfill mode: UPDATE rows in `houses`, INSERT rows in - `address_mismatch_audit` with status 'backfill' / 'no_match' / 'imprecise'. -- Audit mode: INSERT rows in `address_mismatch_audit` with status 'ok' / - 'mismatch' / 'no_match' / 'error'. -- Per-batch progress is logged every 25 rows. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import logging -import os -from dataclasses import dataclass -from datetime import date -from pathlib import Path -from typing import Any - -import httpx -from sqlalchemy import text -from sqlalchemy.orm import Session - -# Allow running both as `python -m scripts.backfill_house_coords` (preferred) -# and as a stand-alone file. Mirrors the audit_address_mismatch import dance. -try: - from app.core.db import SessionLocal # type: ignore[import-not-found] -except ImportError: # pragma: no cover — fallback for adhoc invocation - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - from app.core.db import SessionLocal - -try: - from scripts._yandex_reverse import ( # type: ignore[import-not-found] - YandexReverseResult, - forward_via_api, - reverse_via_api, - ) -except ImportError: - from _yandex_reverse import ( # type: ignore[no-redef] - YandexReverseResult, - forward_via_api, - reverse_via_api, - ) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", -) -logger = logging.getLogger("backfill_house_coords") - -# Yandex Geocoder service limits per docs (as of 2026-05): -# - 25k requests/day free tier -# - 25 requests/sec sustained -# 50ms between calls = ~20 req/sec, leaving headroom for connection ramp-up. -_REQUEST_DELAY_S = 0.05 - -# Precision values we ACCEPT for backfill — anything else means Yandex didn't -# resolve to a specific building, and writing the result back into houses -# would degrade matching recall. -# `exact` → match found at the exact address (best case) -# `number` → house number matched, but unit/entrance unspecified (acceptable) -# `near` / `range` / `street` / `other` / None → skipped (logged for analysis). -_BACKFILL_OK_PRECISION = frozenset({"exact", "number"}) - -# Audit threshold per issue #582 — distances above this flag a "mismatch" -# (the row still goes in the audit table, just with status='mismatch' for -# the report SQL to bucket separately). -_MISMATCH_DISTANCE_M = 50.0 - - -# --------------------------------------------------------------------------- -# Domain types -# --------------------------------------------------------------------------- - - -@dataclass -class HouseRow: - """One house from the source query — minimal fields needed for geocode.""" - - id: int - address: str - lat: float | None - lon: float | None - - -# --------------------------------------------------------------------------- -# Source-row queries -# --------------------------------------------------------------------------- - - -def _select_houses_without_coords(db: Session, limit: int | None) -> list[HouseRow]: - """Pull houses needing forward geocode (lat IS NULL OR lon IS NULL). - - Skips rows with empty address — there's nothing to geocode there, they - need a separate cleanup pass. - """ - sql = ( - "SELECT id, address, lat, lon " - "FROM houses " - "WHERE (lat IS NULL OR lon IS NULL) " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0 " - "ORDER BY id" - ) - if limit is not None: - sql += " LIMIT CAST(:limit AS int)" - rows = db.execute(text(sql), {"limit": limit}).mappings().all() - else: - rows = db.execute(text(sql)).mappings().all() - return [ - HouseRow(id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"]) for r in rows - ] - - -def _select_houses_with_coords(db: Session, limit: int | None) -> list[HouseRow]: - """Pull houses needing reverse audit (both lat AND lon present).""" - sql = ( - "SELECT id, address, lat, lon " - "FROM houses " - "WHERE lat IS NOT NULL " - " AND lon IS NOT NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0 " - "ORDER BY id" - ) - if limit is not None: - sql += " LIMIT CAST(:limit AS int)" - rows = db.execute(text(sql), {"limit": limit}).mappings().all() - else: - rows = db.execute(text(sql)).mappings().all() - return [ - HouseRow(id=r["id"], address=r["address"], lat=r["lat"], lon=r["lon"]) for r in rows - ] - - -def _already_processed_ids(db: Session, batch: str) -> set[int]: - """house_ids already in address_mismatch_audit for this batch → skip set.""" - rows = db.execute( - text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"), - {"b": batch}, - ).all() - return {r[0] for r in rows} - - -# --------------------------------------------------------------------------- -# Distance helper — PostGIS, lon/lat order -# --------------------------------------------------------------------------- - - -def _distance_meters( - db: Session, olat: float, olon: float, slat: float, slon: float -) -> float | None: - """Great-circle distance (meters) via PostGIS geography type. - - Lifted from `audit_address_mismatch.py` to keep the two scripts using - the same authority for distance computation. ST_MakePoint takes lon - first per PostGIS convention. - """ - row = db.execute( - text( - "SELECT ST_Distance(" - " ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), " - " CAST(:olat AS double precision)), 4326)::geography, " - " ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), " - " CAST(:slat AS double precision)), 4326)::geography" - ") AS m" - ), - {"olat": olat, "olon": olon, "slat": slat, "slon": slon}, - ).first() - if row is None or row[0] is None: - return None - return float(row[0]) - - -# --------------------------------------------------------------------------- -# DB writers -# --------------------------------------------------------------------------- - - -def _update_house_coords( - db: Session, - *, - house_id: int, - lat: float, - lon: float, - payload: dict[str, Any], -) -> None: - """UPDATE houses SET lat/lon + merge yandex_geocode into raw_payload. - - The `houses_set_geom_trg` BEFORE UPDATE trigger (009_houses.sql) maintains - `geom` automatically when lat/lon change, so we don't need to set geom - explicitly here. `raw_payload || jsonb_build_object(...)` is the idiomatic - psycopg-safe way to merge — single ALTER, no read-modify-write race. - """ - db.execute( - text( - "UPDATE houses " - " SET lat = CAST(:lat AS double precision), " - " lon = CAST(:lon AS double precision), " - " raw_payload = COALESCE(raw_payload, '{}'::jsonb) " - " || jsonb_build_object('yandex_geocode', " - " CAST(:payload AS jsonb)) " - " WHERE id = CAST(:id AS bigint)" - ), - {"id": house_id, "lat": lat, "lon": lon, "payload": json.dumps(payload)}, - ) - - -def _insert_audit_row( - db: Session, - *, - house_id: int, - batch: str, - original_address: str | None, - original_lat: float | None, - original_lon: float | None, - snapped_address: str | None, - snapped_lat: float | None, - snapped_lon: float | None, - distance_m: float | None, - audit_status: str, - error_message: str | None, - raw_payload: dict[str, Any] | None, -) -> None: - """INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit. - - Same column shape as `audit_address_mismatch._insert_audit_row` but the - `district` and `street_differs` fields are left NULL — backfill/audit - here doesn't have a stratification basis and we let the report SQL - derive district at query time if needed (via Yandex address parse). - - Caller wraps in `begin_nested()` per backend.md SAVEPOINT pattern. - """ - db.execute( - text( - "INSERT INTO address_mismatch_audit (" - " house_id, audit_batch, district," - " original_address, original_lat, original_lon," - " snapped_address, snapped_lat, snapped_lon," - " distance_m, street_differs," - " audit_status, error_message, raw_payload" - ") VALUES (" - " CAST(:house_id AS bigint), CAST(:batch AS text), NULL," - " :original_address, :original_lat, :original_lon," - " :snapped_address, :snapped_lat, :snapped_lon," - " :distance_m, NULL," - " CAST(:audit_status AS text), :error_message," - " CAST(:raw_payload AS jsonb)" - ") ON CONFLICT (house_id, audit_batch) DO NOTHING" - ), - { - "house_id": house_id, - "batch": batch, - "original_address": original_address, - "original_lat": original_lat, - "original_lon": original_lon, - "snapped_address": snapped_address, - "snapped_lat": snapped_lat, - "snapped_lon": snapped_lon, - "distance_m": distance_m, - "audit_status": audit_status, - "error_message": error_message, - "raw_payload": json.dumps(raw_payload) if raw_payload is not None else None, - }, - ) - - -# --------------------------------------------------------------------------- -# Backfill loop (forward geocode, lat IS NULL houses) -# --------------------------------------------------------------------------- - - -def _classify_backfill_status(res: YandexReverseResult | None) -> str: - """Translate a forward-geocode result into an audit_status value. - - 'backfill' — Yandex returned a precise hit, lat/lon will be written. - 'imprecise' — match returned but precision is too low (street/other/...). - 'no_match' — Yandex returned an empty featureMember. - 'error' — handled by the caller's exception branch. - """ - if res is None or res.address is None: - return "no_match" - if res.precision not in _BACKFILL_OK_PRECISION: - return "imprecise" - if res.snapped_lat is None or res.snapped_lon is None: - return "no_match" - return "backfill" - - -async def _run_backfill_mode( - db: Session, sample: list[HouseRow], batch: str, api_key: str -) -> int: - """Forward-geocode each house, UPDATE coords on precise hits, audit-log all.""" - processed = 0 - updated = 0 - n_imprecise = 0 - n_no_match = 0 - n_error = 0 - - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: - for i, row in enumerate(sample, start=1): - status = "backfill" - err: str | None = None - res: YandexReverseResult | None = None - try: - res = await forward_via_api(row.address, api_key, client=client) - except httpx.HTTPError as e: - status = "error" - err = f"http_error: {e!s}" - n_error += 1 - except Exception as e: # pragma: no cover — defensive - status = "error" - err = f"unhandled: {e!s}" - n_error += 1 - - if status != "error": - status = _classify_backfill_status(res) - if status == "imprecise": - n_imprecise += 1 - elif status == "no_match": - n_no_match += 1 - - try: - with db.begin_nested(): - if status == "backfill" and res is not None and res.snapped_lat is not None: - # safe: status='backfill' guarantees snapped_lat/lon non-None. - assert res.snapped_lon is not None - _update_house_coords( - db, - house_id=row.id, - lat=res.snapped_lat, - lon=res.snapped_lon, - payload={ - "address": res.address, - "precision": res.precision, - "kind": res.kind, - "batch": batch, - "source": "yandex_geocoder_api", - }, - ) - updated += 1 - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=None, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - # Per-row commit so resume picks up exactly where we crashed. - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("backfill insert failed for house_id=%s: %s", row.id, e) - - if i % 25 == 0: - logger.info( - "backfill progress %d/%d updated=%d imprecise=%d no_match=%d error=%d", - i, - len(sample), - updated, - n_imprecise, - n_no_match, - n_error, - ) - - # Yandex 25 req/sec → 50ms between calls is plenty of headroom. - if i < len(sample): - await asyncio.sleep(_REQUEST_DELAY_S) - - logger.info( - "backfill done: processed=%d updated=%d imprecise=%d no_match=%d error=%d", - processed, - updated, - n_imprecise, - n_no_match, - n_error, - ) - return processed - - -# --------------------------------------------------------------------------- -# Audit-only loop (reverse geocode, lat IS NOT NULL houses) -# --------------------------------------------------------------------------- - - -async def _run_audit_mode( - db: Session, sample: list[HouseRow], batch: str, api_key: str -) -> int: - """Reverse-geocode each house, compute distance, audit-log status/mismatch.""" - processed = 0 - n_ok = 0 - n_mismatch = 0 - n_no_match = 0 - n_error = 0 - - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: - for i, row in enumerate(sample, start=1): - # Type-narrow: audit mode only feeds rows with non-null coords. - assert row.lat is not None and row.lon is not None - status = "ok" - err: str | None = None - res: YandexReverseResult | None = None - try: - res = await reverse_via_api(row.lat, row.lon, api_key, client=client) - except httpx.HTTPError as e: - status = "error" - err = f"http_error: {e!s}" - n_error += 1 - except Exception as e: # pragma: no cover — defensive - status = "error" - err = f"unhandled: {e!s}" - n_error += 1 - - distance = None - if res is not None and status == "ok": - if res.address is None: - status = "no_match" - n_no_match += 1 - else: - if res.snapped_lat is not None and res.snapped_lon is not None: - distance = _distance_meters( - db, row.lat, row.lon, res.snapped_lat, res.snapped_lon - ) - if distance is not None and distance > _MISMATCH_DISTANCE_M: - status = "mismatch" - n_mismatch += 1 - else: - n_ok += 1 - else: - n_ok += 1 - - try: - with db.begin_nested(): - _insert_audit_row( - db, - house_id=row.id, - batch=batch, - original_address=row.address, - original_lat=row.lat, - original_lon=row.lon, - snapped_address=res.address if res else None, - snapped_lat=res.snapped_lat if res else None, - snapped_lon=res.snapped_lon if res else None, - distance_m=distance, - audit_status=status, - error_message=err, - raw_payload=res.raw if res else None, - ) - db.commit() - processed += 1 - except Exception as e: - db.rollback() - logger.warning("audit insert failed for house_id=%s: %s", row.id, e) - - if i % 25 == 0: - logger.info( - "audit progress %d/%d ok=%d mismatch=%d no_match=%d error=%d", - i, - len(sample), - n_ok, - n_mismatch, - n_no_match, - n_error, - ) - - if i < len(sample): - await asyncio.sleep(_REQUEST_DELAY_S) - - logger.info( - "audit done: processed=%d ok=%d mismatch=%d no_match=%d error=%d", - processed, - n_ok, - n_mismatch, - n_no_match, - n_error, - ) - return processed - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """argparse setup, factored out for testability.""" - p = argparse.ArgumentParser( - description=( - "Phase 2-3 of issue #582 — backfill houses.lat/lon via Yandex forward " - "geocode, or audit already-geocoded houses via reverse geocode." - ), - ) - p.add_argument( - "--batch", - default=f"{date.today().isoformat()}_backfill", - help="Audit batch label. Same batch re-run skips already-processed houses.", - ) - p.add_argument( - "--audit-only", - action="store_true", - help=( - "Run reverse-geocode audit on houses WITH coords instead of forward " - "backfill on houses WITHOUT coords. Does not modify the houses table." - ), - ) - p.add_argument( - "--limit", - type=int, - default=None, - help=( - "Optional cap on source-row count. Useful for canary runs " - "(e.g. --limit 100 before letting the full 4k loose)." - ), - ) - return p.parse_args(argv) - - -async def main(argv: list[str] | None = None) -> int: - """CLI entry point. Returns the number of rows processed this run.""" - args = _parse_args(argv) - api_key = os.environ.get("YANDEX_GEOCODER_API_KEY") - if not api_key: - raise SystemExit( - "YANDEX_GEOCODER_API_KEY is required — forward geocode is API-only." - ) - - mode = "audit" if args.audit_only else "backfill" - logger.info( - "starting batch=%s mode=%s limit=%s", - args.batch, - mode, - args.limit if args.limit is not None else "all", - ) - - db = SessionLocal() - try: - if args.audit_only: - sample = _select_houses_with_coords(db, args.limit) - else: - sample = _select_houses_without_coords(db, args.limit) - logger.info("loaded source rows: %d", len(sample)) - - done = _already_processed_ids(db, args.batch) - if done: - logger.info( - "resuming batch %s: %d rows already processed", - args.batch, - len(done), - ) - remaining = [s for s in sample if s.id not in done] - if not remaining: - logger.info("nothing to do — batch %s is complete for the loaded sample", args.batch) - return 0 - - if args.audit_only: - n = await _run_audit_mode(db, remaining, args.batch, api_key) - else: - n = await _run_backfill_mode(db, remaining, args.batch, api_key) - - logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode) - return n - finally: - db.close() - - -if __name__ == "__main__": # pragma: no cover - asyncio.run(main()) diff --git a/tradein-mvp/backend/scripts/backfill_listing_sources.py b/tradein-mvp/backend/scripts/backfill_listing_sources.py index ad0b4283..e3e8c8ba 100644 --- a/tradein-mvp/backend/scripts/backfill_listing_sources.py +++ b/tradein-mvp/backend/scripts/backfill_listing_sources.py @@ -291,8 +291,8 @@ def _link_listing_to_house( lat=row.lat, lon=row.lon, year_built=row.year_built, + # Кадастр КВАРТИРЫ (row.cadastral_number) в ключ дома не идёт (#2674). building_cadastral_number=row.building_cadastral_number, - cadastral_number=row.cadastral_number, source_url=row.house_url or row.source_url, ) house_resolved = house_id is not None diff --git a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py index 80947023..c6876d83 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py +++ b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py @@ -18,12 +18,31 @@ result into `geocode_cache`. We do NOT call Nominatim directly — that keeps a single source of truth for provider order, ЕКБ bbox filtering, and the 1 req/sec policy. -Why dedup by address (not one call per row) -------------------------------------------- -`deals.address` is street-only ('Екатеринбург, '), so thousands of rows -share the same address. The geocoder caches by normalized address, but we also -GROUP BY address up front so the real call count is driven by DISTINCT streets, -not the ~6,951 row backlog. One geocode call → UPDATE every deal on that street. +Why dedup by (address, city) — not one call per row +--------------------------------------------------- +`deals.address` is street-only (', '), so thousands of rows share +the same address. The geocoder caches by normalized address, but we also GROUP +BY up front so the real call count is driven by DISTINCT streets, not the +~6,951 row backlog. One geocode call → UPDATE every deal on that street. + +Grouping is by the PAIR (address, city), not by address alone (#2603, same +shape as #2601 fixed in app/tasks/geocode_missing.py): the same address text in +two different cities must not collapse into one geocode call, and the UPDATE +must not spill onto the other city's rows — hence `city IS NOT DISTINCT FROM` +(plain `=` never matches a NULL city, so a NULL-city group would update nothing). + +City hint (and why it is filtered) +---------------------------------- +`deals.city` comes from Rosreestr (migration 177) and is filled on 100% of the +rows, so a hint is available for every group — but its long tail holds values +that are not cities at all ('Бессонова', 'Бердюгина', 'Билейский рыбопитомник'). +A non-EKB hint is a HARD signal inside the geocoder: it closes the EKB-only +local tiers (`_ekb_local_tiers_allowed`) and gets prefixed into the provider +query, so a junk hint makes the result strictly WORSE than no hint at all. +We therefore pass the hint only for values that are recognised cities of oblast +66 — `geocoder.known_city_hint`, shared with the other DB-column callers +(`app/api/v1/admin.py`, `app/tasks/geocode_missing.py`); everything else +degrades to the previous behaviour (no hint, address text only). Street-level precision is accepted: the estimator's comparable search uses a 1000-2000 m radius, so a street-level point lands every deal on that street in @@ -89,12 +108,13 @@ try: from app.services.geocoder import ( # type: ignore[import-not-found] GeocodeResult, geocode, + known_city_hint, ) except ImportError: # pragma: no cover import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - from app.services.geocoder import GeocodeResult, geocode + from app.services.geocoder import GeocodeResult, geocode, known_city_hint logging.basicConfig( level=logging.INFO, @@ -122,17 +142,23 @@ _LOG_EVERY = 25 @dataclass class AddressGroup: - """One distinct deals.address and how many lat-IS-NULL rows share it.""" + """One distinct (deals.address, deals.city) pair + its lat-IS-NULL row count. + + `city` is the raw Rosreestr value (may be NULL for non-rosreestr sources, and + may be junk — see `geocoder.known_city_hint`), kept verbatim so the UPDATE + can target exactly the rows this group came from. + """ address: str deals_count: int + city: str | None = None @dataclass class Stats: """Final-summary counters. - - processed distinct addresses fed to the geocoder this run. + - processed distinct (address, city) groups fed to the geocoder this run. - geocoded addresses the geocoder resolved to coords. - geocode_failed addresses the geocoder returned None for (still stamped). - skipped distinct addresses skipped before any geocode call @@ -156,10 +182,8 @@ class Stats: # --------------------------------------------------------------------------- -def _select_pending_addresses( - db: Session, *, limit: int, stale_days: int -) -> list[AddressGroup]: - """Distinct deals.address still needing coords — resume-safe candidate set. +def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> list[AddressGroup]: + """Distinct (address, city) pairs still needing coords — resume-safe set. Combines the `deals_geocode_pending_idx` partial index predicate (`lat IS NULL`) with a staleness filter so failed/un-geocodable addresses @@ -167,44 +191,68 @@ def _select_pending_addresses( The GROUP BY collapses the ~6,951-row backlog into its distinct streets; `:limit` caps the deal ROWS fanned out, computed from the running SUM of - per-address counts so a single huge street can't blow past the cap. We - order by occurrence DESC (biggest ROI per geocode call first) then address - for a deterministic resume order. + per-group counts so a single huge street can't blow past the cap. We order + by occurrence DESC (biggest ROI per geocode call first) then address, city + for a deterministic resume order (the window ORDER BY must match the outer + one, otherwise the running_rows cap slices a different ordering). + + GROUP BY address, city — NOT address alone (#2603, the shape #2601 fixed in + app/tasks/geocode_missing.py): the same street name in two cities is two + geocode calls with two different hints, not one call whose result lands on + both. SQL groups NULL cities together, so a NULL-city group stays its own + group rather than merging into an arbitrary city. """ - rows = db.execute( - text( - "SELECT address, deals_count FROM (" - " SELECT address, " - " COUNT(*) AS deals_count, " - " SUM(COUNT(*)) OVER (" - " ORDER BY COUNT(*) DESC, address ASC" - " ) AS running_rows " - " FROM deals " - " WHERE lat IS NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) >= 3 " - " AND (geocode_tried_at IS NULL " - " OR geocode_tried_at < NOW() " - " - make_interval(days => CAST(:stale_days AS int))) " - " GROUP BY address " - ") g " - "WHERE running_rows - deals_count < CAST(:limit AS int) " - "ORDER BY deals_count DESC, address ASC" - ), - {"limit": limit, "stale_days": stale_days}, - ).mappings().all() - return [AddressGroup(address=r["address"], deals_count=r["deals_count"]) for r in rows] + rows = ( + db.execute( + text( + "SELECT address, city, deals_count FROM (" + " SELECT address, city, " + " COUNT(*) AS deals_count, " + " SUM(COUNT(*)) OVER (" + " ORDER BY COUNT(*) DESC, address ASC, city ASC NULLS FIRST" + " ) AS running_rows " + " FROM deals " + " WHERE lat IS NULL " + " AND address IS NOT NULL " + " AND length(trim(address)) >= 3 " + " AND (geocode_tried_at IS NULL " + " OR geocode_tried_at < NOW() " + " - make_interval(days => CAST(:stale_days AS int))) " + " GROUP BY address, city " + ") g " + "WHERE running_rows - deals_count < CAST(:limit AS int) " + "ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST" + ), + {"limit": limit, "stale_days": stale_days}, + ) + .mappings() + .all() + ) + return [ + AddressGroup( + address=r["address"], + city=r.get("city"), + deals_count=r["deals_count"], + ) + for r in rows + ] def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]: - """Full backlog: (distinct addresses, total rows) eligible this pass. + """Full backlog: (distinct (address, city) pairs, total rows) this pass. Denominators for the dry-run projection — counts every lat-IS-NULL deal - that passes the staleness filter, ignoring --limit. + that passes the staleness filter, ignoring --limit. Counts PAIRS, matching + what `_select_pending_addresses` actually feeds the geocoder (#2603) — + counting distinct addresses here would understate the denominator and the + projection would read above 100%. COALESCE(city, '') keeps a NULL-city pair + countable: `count()` skips NULL inputs, and a bare row expression with a + NULL field invites exactly that argument — the empty string can't collide + with a real city name, so the pair count stays honest either way. """ row = db.execute( text( - "SELECT COUNT(DISTINCT address) AS streets, COUNT(*) AS rows " + "SELECT COUNT(DISTINCT (address, COALESCE(city, ''))) AS streets, COUNT(*) AS rows " "FROM deals " "WHERE lat IS NULL " " AND address IS NOT NULL " @@ -225,8 +273,10 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]: # --------------------------------------------------------------------------- -def _update_deals_geocoded(db: Session, *, address: str, lat: float, lon: float) -> int: - """UPDATE every lat-IS-NULL deal on `address`; geom auto-fills via trigger. +def _update_deals_geocoded( + db: Session, *, address: str, city: str | None, lat: float, lon: float +) -> int: + """UPDATE every lat-IS-NULL deal on (address, city); geom via trigger. The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger (002_core_tables.sql, reuses listings_set_geom()) populates geom from the @@ -234,6 +284,12 @@ def _update_deals_geocoded(db: Session, *, address: str, lat: float, lon: float) so the row drops out of the candidate set. The `AND lat IS NULL` guard keeps this idempotent and avoids clobbering rows another pass already set. + `city IS NOT DISTINCT FROM` (not `=`) scopes the write to the group the + geocode was made for: plain `=` is never true for a NULL city, so a + NULL-city group would update zero rows and re-run forever; `IS NOT DISTINCT + FROM` treats NULL=NULL as a match while staying strict for a real city, so + the same street text in another city keeps its own coords (#2603). + Returns the number of deal rows updated. """ result = db.execute( @@ -243,14 +299,15 @@ def _update_deals_geocoded(db: Session, *, address: str, lat: float, lon: float) " lon = CAST(:lon AS double precision), " " geocode_tried_at = NOW() " " WHERE address = CAST(:addr AS text) " + " AND city IS NOT DISTINCT FROM CAST(:city AS text) " " AND lat IS NULL" ), - {"addr": address, "lat": lat, "lon": lon}, + {"addr": address, "city": city, "lat": lat, "lon": lon}, ) return result.rowcount -def _mark_deals_tried(db: Session, *, address: str) -> int: +def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int: """Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss). Critical for resume: an address the geocoder can't resolve must still drop @@ -259,6 +316,9 @@ def _mark_deals_tried(db: Session, *, address: str) -> int: it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success can't be downgraded. + Scoped to the (address, city) pair for the same reason as the coords write + — a miss in one city must not defer the other city's retry (#2603). + Returns the number of deal rows stamped. """ result = db.execute( @@ -266,9 +326,10 @@ def _mark_deals_tried(db: Session, *, address: str) -> int: "UPDATE deals " " SET geocode_tried_at = NOW() " " WHERE address = CAST(:addr AS text) " + " AND city IS NOT DISTINCT FROM CAST(:city AS text) " " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) return result.rowcount @@ -285,7 +346,7 @@ async def _run_backfill( batch: str, dry_run: bool, ) -> Stats: - """For each distinct address: geocode once, then UPDATE all its deals. + """For each (address, city) pair: geocode once, then UPDATE all its deals. Per-address SAVEPOINT (`db.begin_nested()`) so one bad UPDATE can't abort the batch (backend.md SAVEPOINT rule). Per-address commit on success → a @@ -301,6 +362,12 @@ async def _run_backfill( for i, group in enumerate(groups, start=1): address = group.address + city = group.city + # Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr + # values degrade to None (geocoder.known_city_hint — shared with the + # other DB-column callers). The raw `city` is still used for the UPDATE + # scope — it identifies the group either way. + hint = known_city_hint(city) # The geocoder itself rejects <3 chars, but skip here too so the dry-run # report and counters stay honest (no phantom "processed" address). @@ -310,13 +377,14 @@ async def _run_backfill( result: GeocodeResult | None = None try: - result = await geocode(address, db) + result = await geocode(address, db, city_hint=hint) except Exception as exc: # defensive — one geocode error must not kill batch stats.geocode_failed += 1 stats.processed += 1 logger.warning( - "geocode raised for addr=%r (%d deals): %s", + "geocode raised for addr=%r city=%r (%d deals): %s", address[:60], + city, group.deals_count, exc, ) @@ -331,18 +399,21 @@ async def _run_backfill( stats.processed += 1 if dry_run: logger.info( - "DRY-RUN addr=%r (%d deals) → NOT FOUND (would stamp tried)", + "DRY-RUN addr=%r city=%r (%d deals) → NOT FOUND (would stamp tried)", address[:60], + city, group.deals_count, ) else: try: with db.begin_nested(): - _mark_deals_tried(db, address=address) + _mark_deals_tried(db, address=address, city=city) db.commit() except Exception as exc: # defensive — isolate one bad UPDATE db.rollback() - logger.warning("mark_tried failed for addr=%r: %s", address[:60], exc) + logger.warning( + "mark_tried failed for addr=%r city=%r: %s", address[:60], city, exc + ) _maybe_log_progress(i, groups, batch, stats) continue @@ -356,8 +427,9 @@ async def _run_backfill( if dry_run: logger.info( - "DRY-RUN addr=%r → (%.5f, %.5f) provider=%s would update %d deals", + "DRY-RUN addr=%r city=%r → (%.5f, %.5f) provider=%s would update %d deals", address[:60], + city, result.lat, result.lon, result.provider, @@ -369,7 +441,7 @@ async def _run_backfill( try: with db.begin_nested(): n = _update_deals_geocoded( - db, address=address, lat=result.lat, lon=result.lon + db, address=address, city=city, lat=result.lat, lon=result.lon ) # Per-address commit so resume picks up exactly where we crashed. db.commit() @@ -378,7 +450,7 @@ async def _run_backfill( db.rollback() # The geocode itself succeeded (and is cached); only the write # failed. Count the address as geocoded but log the write failure. - logger.warning("db_write failed for addr=%r: %s", address[:60], exc) + logger.warning("db_write failed for addr=%r city=%r: %s", address[:60], city, exc) _maybe_log_progress(i, groups, batch, stats) @@ -513,12 +585,10 @@ async def main(argv: list[str] | None = None) -> int: db = SessionLocal() try: - groups = _select_pending_addresses( - db, limit=args.limit, stale_days=args.stale_days - ) + groups = _select_pending_addresses(db, limit=args.limit, stale_days=args.stale_days) total_rows = sum(g.deals_count for g in groups) logger.info( - "loaded %d distinct addresses (%d deal rows) needing coords", + "loaded %d distinct (address, city) groups (%d deal rows) needing coords", len(groups), total_rows, ) @@ -533,9 +603,7 @@ async def main(argv: list[str] | None = None) -> int: stats = await _run_backfill(db, groups, batch=args.batch, dry_run=args.dry_run) if args.dry_run: - total_streets, backlog_rows = _count_pending_total( - db, stale_days=args.stale_days - ) + total_streets, backlog_rows = _count_pending_total(db, stale_days=args.stale_days) _report_dry_run( stats, total_streets=total_streets, diff --git a/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py b/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py index ff0b65ce..08b6b70a 100644 --- a/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py +++ b/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py @@ -190,6 +190,10 @@ def run(jsonl_path: str, limit: int | None, dry_run: bool) -> dict[str, int]: db = SessionLocal() try: if lots: + # run_id нет и не будет (#2701): разовый ingest файла — не прогон скрапера, + # строки в scrape_runs под него не существует. Именно отсюда 129 908 снимков + # domklik без run_id (2.9% заполнения у источника) — исторические, не текущие: + # ежедневный run_domclick_city_sweep run_id передаёт. inserted, updated = save_listings( db, lots, matcher=RealMatcherAdapter(), region_code=DEFAULT_REGION_CODE ) diff --git a/tradein-mvp/backend/tests/conftest.py b/tradein-mvp/backend/tests/conftest.py index c6660a94..f9d02c57 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -1,13 +1,17 @@ """Repo-wide test config for tradein-mvp/backend. -Currently only registers custom pytest markers so they don't emit -PytestUnknownMarkWarning when used (`--strict-markers` is not enabled in -pyproject.toml, so an unregistered marker would only warn, not fail — this -just keeps output clean and documents intent in one place). +Регистрирует кастомные pytest-маркеры (иначе PytestUnknownMarkWarning: +`--strict-markers` в pyproject.toml не включён, так что незарегистрированный +маркер только предупреждал бы) и сторожит глобальное состояние, которое +переживает отдельный тест, — см. `_no_leaked_password_verify_slots`. """ from __future__ import annotations +import sys + +import pytest + def pytest_configure(config) -> None: config.addinivalue_line( @@ -16,3 +20,40 @@ def pytest_configure(config) -> None: "Pango/cairo/GObject libs, self-skips where unavailable (see " "tests/test_pdf_real_render.py docstring for how to run it for real).", ) + + +@pytest.fixture(autouse=True) +def _no_leaked_password_verify_slots(): + """Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714). + + Счётчики в `app.core.password` — состояние ПРОЦЕССА, а `pytest-asyncio` даёт + каждому тесту свой событийный цикл. Слот освобождает колбэк, посланный в + цикл через `call_soon_threadsafe`; если цикл к тому моменту закрыт, + `_schedule_verify_slot_release` ловит RuntimeError и слот не возвращается + никогда. На проде цикл живёт столько же, сколько процесс, и ветка + недостижима — а в тестах она копится молча и роняет НЕ ТОТ тест, который + её устроил: при пуле в 1 поток пары утечек хватает, чтобы всё дальнейшее + получало 429 «на ровном месте». + + Поэтому проверка тут и общая: считаем слоты после каждого теста. + + `sys.modules.get`, а не import: тестам, которые password.py не трогают + (большинство), незачем тянуть `Settings()` с его требованием DATABASE_URL. + """ + yield + + password_mod = sys.modules.get("app.core.password") + if password_mod is None: + return + + inflight = password_mod._verify_inflight + by_key = dict(password_mod._verify_inflight_by_key) + # Сброс ДО assert: иначе одна утечка красит все последующие тесты и виновник + # теряется среди пострадавших. + password_mod._verify_inflight = 0 + password_mod._verify_inflight_by_key.clear() + + assert inflight == 0 and not by_key, ( + f"тест оставил {inflight} занятых слотов проверки пароля (по ключам: {by_key}) — " + "утечка слота при пуле в 1 поток это вечный 429 всем на входе" + ) diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 55fe9bb3..a040e084 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -7,10 +7,10 @@ "n_covered": 0 }, "low": { - "coverage_pct": 81.88, - "mape_pct": 13.25, + "coverage_pct": 82.09, + "mape_pct": 13.2, "n": 276, - "n_covered": 226 + "n_covered": 220 }, "medium": { "coverage_pct": 100.0, @@ -26,22 +26,22 @@ ], "expected_sold": { "overall": { - "mape_pct": 13.23, - "median_bias_pct": -3.44, - "n": 277, + "mape_pct": 13.18, + "median_bias_pct": -3.71, + "n": 269, "n_no_analogs": 0, - "p25_pct": -15.49, - "p75_pct": 10.07 + "p25_pct": -16.11, + "p75_pct": 8.92 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 18.1, - "median_bias_pct": 16.96, - "n": 37, + "mape_pct": 19.38, + "median_bias_pct": 18.1, + "n": 35, "n_no_analogs": 0, - "p25_pct": 1.37, - "p75_pct": 33.53 + "p25_pct": 1.5, + "p75_pct": 34.32 }, "1": { "label": "1к", @@ -72,12 +72,12 @@ }, "4": { "label": "4+", - "mape_pct": 20.27, - "median_bias_pct": 8.54, - "n": 30, + "mape_pct": 16.3, + "median_bias_pct": 3.34, + "n": 24, "n_no_analogs": 0, - "p25_pct": -6.86, - "p75_pct": 23.54 + "p25_pct": -14.91, + "p75_pct": 15.11 } }, "per_segment": { @@ -89,11 +89,11 @@ "p75_pct": -1.31 }, "комфорт": { - "mape_pct": 11.82, - "median_bias_pct": -4.35, - "n": 104, - "p25_pct": -16.35, - "p75_pct": 6.94 + "mape_pct": 11.8, + "median_bias_pct": -4.61, + "n": 101, + "p25_pct": -17.07, + "p75_pct": 6.04 }, "премиум": { "mape_pct": 68.92, @@ -103,11 +103,11 @@ "p75_pct": -68.92 }, "эконом": { - "mape_pct": 13.96, - "median_bias_pct": 3.39, - "n": 120, - "p25_pct": -9.57, - "p75_pct": 25.7 + "mape_pct": 13.71, + "median_bias_pct": 2.54, + "n": 115, + "p25_pct": -9.8, + "p75_pct": 25.5 }, "элит": { "mape_pct": 33.2, @@ -119,15 +119,15 @@ } }, "headline": { - "ask_median_ppm2": 147545.8502510892, + "ask_median_ppm2": 145883.6593586467, "deal_median_ppm2": 125063.0, - "spread_pct": 17.98 + "spread_pct": 16.65 }, "range_coverage": { "overall": { - "coverage_pct": 81.95, - "n": 277, - "n_covered": 227 + "coverage_pct": 82.16, + "n": 269, + "n_covered": 221 }, "per_confidence": { "high": { @@ -136,9 +136,9 @@ "n_covered": 0 }, "low": { - "coverage_pct": 81.88, - "n": 276, - "n_covered": 226 + "coverage_pct": 82.09, + "n": 268, + "n_covered": 220 }, "medium": { "coverage_pct": 100.0, @@ -149,6 +149,6 @@ }, "sharpness": { "median_rel_width": 0.743, - "n": 277 + "n": 269 } } diff --git a/tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json b/tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json deleted file mode 100644 index 9a3660d0..00000000 --- a/tradein-mvp/backend/tests/fixtures/yandex_geocode_sample.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "response": { - "GeoObjectCollection": { - "metaDataProperty": { - "GeocoderResponseMetaData": { - "request": "60.586,56.838", - "results": "1", - "found": "1" - } - }, - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "precision": "exact", - "text": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51", - "kind": "house", - "Address": { - "country_code": "RU", - "formatted": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51", - "postal_code": "620075", - "Components": [ - {"kind": "country", "name": "Россия"}, - {"kind": "province", "name": "Уральский федеральный округ"}, - {"kind": "province", "name": "Свердловская область"}, - {"kind": "area", "name": "городской округ Екатеринбург"}, - {"kind": "locality", "name": "Екатеринбург"}, - {"kind": "street", "name": "улица Малышева"}, - {"kind": "house", "name": "51"} - ] - }, - "AddressDetails": { - "Country": { - "AddressLine": "Россия, Свердловская область, Екатеринбург, улица Малышева, 51", - "CountryNameCode": "RU", - "CountryName": "Россия", - "AdministrativeArea": { - "AdministrativeAreaName": "Свердловская область", - "SubAdministrativeArea": { - "SubAdministrativeAreaName": "городской округ Екатеринбург", - "Locality": { - "LocalityName": "Екатеринбург", - "Thoroughfare": { - "ThoroughfareName": "улица Малышева", - "Premise": { - "PremiseNumber": "51", - "PostalCode": {"PostalCodeNumber": "620075"} - } - } - } - } - } - } - } - } - }, - "name": "улица Малышева, 51", - "description": "Екатеринбург, Россия", - "boundedBy": { - "Envelope": { - "lowerCorner": "60.585217 56.837461", - "upperCorner": "60.587094 56.838547" - } - }, - "Point": { - "pos": "60.586155 56.838004" - } - } - } - ] - } - } -} diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index d5c96c0e..3b46b334 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -42,6 +42,7 @@ from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.providers.domclick.detail import ( DomClickDetailEnrichment, _extract_ssr_state, + canon_sale_type, fetch_detail, parse_detail_html, save_detail_enrichment, @@ -159,6 +160,25 @@ def test_extract_ssr_state_challenge_raises_blocked() -> None: _extract_ssr_state(html) +# ── QRATOR markers (#2636) — Layer B had only 4/7 canonical markers, so a +# QRATOR block page using bot_mitigation/система защиты/403 | домклик fell +# through to DomClickParseError instead of DomClickBlockedError (block-breaker +# never triggered, batch burned through instead of pausing). Case varied per +# marker to exercise the case-insensitive comparison. +@pytest.mark.parametrize( + "html", + [ + "BOT_MITIGATION в процессе, подождите", + "СИСТЕМА ЗАЩИТЫ от ботов активна", + "403 | ДОМКЛИК — доступ ограничен", + ], + ids=["bot_mitigation_upper", "sistema_zashchity_upper", "403_domklik_upper"], +) +def test_extract_ssr_state_qrator_markers_raise_blocked_not_parse(html: str) -> None: + with pytest.raises(DomClickBlockedError): + _extract_ssr_state(html) + + def test_extract_ssr_state_unbalanced_raises_parse() -> None: with pytest.raises(DomClickParseError): _extract_ssr_state('window.__SSR_STATE__ = {"a": 1') @@ -388,6 +408,36 @@ async def test_fetch_detail_propagates_blocked_from_html() -> None: await fetch_detail(_CARD_URL, browser_fetcher=bf) +# ── fetch_detail: report_ban на ГЕНУИННЫЙ маркер-детект (#2600 п.1) ──────────── +# +# Различие: QRATOR-маркеры в HTML (parse_detail_html) — надёжно распознанный бан, +# report_ban ДОЛЖЕН вызываться. Голая ошибка транспорта (bf.fetch кинул) — это +# сетевой/инфраструктурный сбой, НЕ подтверждённый бан-маркер, report_ban НЕ +# вызывается (issue #2600 п.4 — не смешивать «бан» и «сетевой сбой»). + + +@pytest.mark.asyncio +async def test_fetch_detail_reports_ban_on_marker_detected_block() -> None: + bf = MagicMock() + bf.fetch = AsyncMock(return_value="Access denied datadome") + with pytest.raises(DomClickBlockedError): + await fetch_detail(_CARD_URL, browser_fetcher=bf) + bf.report_ban.assert_called_once() + assert _CARD_URL in bf.report_ban.call_args.args[0] + + +@pytest.mark.asyncio +async def test_fetch_detail_transport_failure_does_not_report_ban() -> None: + """bf.fetch() кинул (502/timeout/network) — DomClickBlockedError поднимается как + обёртка (см. except Exception ветку fetch_detail), но report_ban НЕ вызывается — + это не подтверждённый маркер-бан, а транспортная ошибка.""" + bf = MagicMock() + bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway")) + with pytest.raises(DomClickBlockedError): + await fetch_detail(_CARD_URL, browser_fetcher=bf) + bf.report_ban.assert_not_called() + + # ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ───────────── @@ -404,7 +454,7 @@ def test_save_detail_enrichment_writes_columns() -> None: kitchen_area_m2=12.3, balconies_count=2, has_balcony=True, - sale_type="свободная", + sale_type="Свободная продажа", owners_count=1, encumbrances_clean=False, year_built=2015, @@ -426,6 +476,8 @@ def test_save_detail_enrichment_writes_columns() -> None: assert update_params["balconies_count"] == 2 assert update_params["has_balcony"] is True assert update_params["encumbrances_clean"] is False + # #2674: в колонку уезжает общий с cian/avito токен, а не русская фраза. + assert update_params["sale_type"] == "free" assert "монолит" in update_params["raw_extra"] # price-history INSERT с ON CONFLICT (идемпотентность) @@ -548,3 +600,45 @@ def test_clamp_diff_percent_none() -> None: def test_clamp_diff_percent_bool_treated_as_none() -> None: assert clamp_diff_percent(True) is None assert clamp_diff_percent(False) is None + + +# ── canon_sale_type (#2674) ─────────────────────────────────────────────────── +# Кейсы — ФАКТИЧЕСКИЙ словарь прода на 2026-08-06: +# SELECT source, sale_type, count(*) FROM listings GROUP BY 1,2 → +# domklik: «Свободная продажа» 5546 · «Альтернатива» 553 · «Первичная продажа» 2. +# Токены-цели — те, что уже пишут cian (free 14800 / alternative 1327) и +# avito (free 2976). Третий словарь не заводим. + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("Свободная продажа", "free"), + ("Альтернатива", "alternative"), + # Регистр/пробелы источника не должны плодить новые значения в колонке. + ("свободная продажа", "free"), + (" Альтернатива ", "alternative"), + # Соответствия в чужих словарях нет: первичка у cian — это ТИП ДОГОВОРА + # (fz214/dzhsk/pdkp/dupt), маркетинговая метка его не называет. Не угадываем. + ("Первичная продажа", None), + # Незнакомое/пустое → None → COALESCE в UPDATE не затрёт существующее. + ("Что-то новое", None), + ("", None), + (None, None), + ], +) +def test_canon_sale_type_covers_every_prod_value(raw: str | None, expected: str | None) -> None: + assert canon_sale_type(raw) == expected + + +def test_canon_sale_type_is_idempotent_on_canon_tokens() -> None: + """Токен уже каноничен (повторное обогащение той же строки) → не теряем его.""" + assert canon_sale_type("free") is None # чужой токен на вход не подаётся… + # …а вот через save_detail_enrichment COALESCE защищает уже записанное: + db = MagicMock() + db.execute.return_value.rowcount = 1 + e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, sale_type="Первичная продажа") + save_detail_enrichment(db, 1, e) + params = db.execute.call_args_list[0][0][1] + assert params["sale_type"] is None + assert "COALESCE(:sale_type, sale_type)" in str(db.execute.call_args_list[0][0][0]) diff --git a/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py b/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py index 794555cc..3e656a98 100644 --- a/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py +++ b/tradein-mvp/backend/tests/scripts/test_geocode_deals_nominatim.py @@ -12,6 +12,10 @@ Coverage (per the issue's test plan): batch. - dedup: distinct addresses drive geocode call count (one call per address). - main() wiring: SessionLocal, --limit bind, returns geocoded count. + - #2603: grouping by the (address, city) PAIR, city_hint filtered through + the shared `geocoder.known_city_hint` (junk Rosreestr city → no hint; the + helper's own cases live in tests/test_geocoder_city_hint.py), and writes + scoped with `city IS NOT DISTINCT FROM`. No real Postgres. `geocode()` is async → patched with AsyncMock (mirrors tests/tasks/test_geocode_missing.py). The Session is a MagicMock that routes @@ -93,7 +97,7 @@ def _make_db_mock( tried_updates.append(dict(params) if params else {}) result.rowcount = update_rowcount return result - if "COUNT(DISTINCT address)" in sql_str: + if "COUNT(DISTINCT" in sql_str: result.first.return_value = (total_streets, total_rows) return result if "FROM deals" in sql_str: @@ -176,13 +180,16 @@ async def test_mark_deals_tried_sql_stamps_tried_at_only(): """The mark-tried writer sets geocode_tried_at and nothing else (no lat/lon).""" db = MagicMock() db.execute.return_value = MagicMock(rowcount=4) - n = _mark_deals_tried(db, address="Екатеринбург, X") + n = _mark_deals_tried(db, address="Екатеринбург, X", city="Екатеринбург") args, _kw = db.execute.call_args sql_str = str(args[0]) assert "UPDATE deals" in sql_str assert "geocode_tried_at = NOW()" in sql_str assert "SET lat" not in sql_str assert "lat IS NULL" in sql_str # idempotency / no-clobber guard + # #2603: a miss in one city must not defer the other city's retry. + assert "city IS NOT DISTINCT FROM CAST(:city AS text)" in sql_str + assert args[1]["city"] == "Екатеринбург" assert n == 4 @@ -312,7 +319,9 @@ async def test_run_backfill_db_write_failure_isolated_to_address(): def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom(): db = MagicMock() db.execute.return_value = MagicMock(rowcount=7) - n = _update_deals_geocoded(db, address="Екатеринбург, Y", lat=56.1, lon=60.2) + n = _update_deals_geocoded( + db, address="Екатеринбург, Y", city="Екатеринбург", lat=56.1, lon=60.2 + ) args, _kw = db.execute.call_args sql_str = str(args[0]) binds = args[1] @@ -321,10 +330,28 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom(): # geom must NOT be set manually — the deals_set_geom_trg trigger fills it. assert "geom" not in sql_str assert "lat IS NULL" in sql_str # no-clobber guard - assert binds == {"addr": "Екатеринбург, Y", "lat": 56.1, "lon": 60.2} + # #2603: the write is scoped to the (address, city) group it was made for. + assert "city IS NOT DISTINCT FROM CAST(:city AS text)" in sql_str + assert binds == { + "addr": "Екатеринбург, Y", + "city": "Екатеринбург", + "lat": 56.1, + "lon": 60.2, + } assert n == 7 +def test_update_deals_geocoded_null_city_group_uses_null_safe_predicate(): + """A NULL-city group must still update its rows: plain `city = NULL` is never + true, so `IS NOT DISTINCT FROM` is what keeps those rows reachable.""" + db = MagicMock() + db.execute.return_value = MagicMock(rowcount=2) + _update_deals_geocoded(db, address="Тестовая, 1", city=None, lat=56.1, lon=60.2) + args, _kw = db.execute.call_args + assert "city IS NOT DISTINCT FROM CAST(:city AS text)" in str(args[0]) + assert args[1]["city"] is None + + # --------------------------------------------------------------------------- # resume filter — candidate SQL carries lat IS NULL + staleness predicate # --------------------------------------------------------------------------- @@ -333,23 +360,19 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom(): def test_select_pending_addresses_filters_null_and_stale(): """The candidate query must combine lat IS NULL with the staleness window and bind --stale-days, so already-tried-recently rows are excluded.""" - db, _, _ = _make_db_mock( - address_rows=[{"address": "Екатеринбург, Z", "deals_count": 3}] - ) + db, _, _ = _make_db_mock(address_rows=[{"address": "Екатеринбург, Z", "deals_count": 3}]) groups = _select_pending_addresses(db, limit=2000, stale_days=30) assert groups == [AddressGroup(address="Екатеринбург, Z", deals_count=3)] # Inspect the SQL + binds of the SELECT. - select_call = next( - c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]) - ) + select_call = next(c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0])) sql_str = str(select_call[0][0]) binds = select_call[0][1] assert "lat IS NULL" in sql_str assert "geocode_tried_at IS NULL" in sql_str assert "make_interval(days => CAST(:stale_days AS int))" in sql_str - assert "GROUP BY address" in sql_str + assert "GROUP BY address, city" in sql_str # #2603 — pair, not address alone assert binds["stale_days"] == 30 assert binds["limit"] == 2000 @@ -357,9 +380,7 @@ def test_select_pending_addresses_filters_null_and_stale(): def test_select_pending_addresses_passes_custom_stale_days(): db, _, _ = _make_db_mock(address_rows=[]) _select_pending_addresses(db, limit=500, stale_days=7) - select_call = next( - c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]) - ) + select_call = next(c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0])) binds = select_call[0][1] assert binds["stale_days"] == 7 assert binds["limit"] == 500 @@ -419,9 +440,7 @@ async def test_main_respects_limit_bind(): ): await main(["--limit", "50"]) - select_call = next( - c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]) - ) + select_call = next(c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0])) assert select_call[0][1]["limit"] == 50 @@ -440,6 +459,97 @@ async def test_main_no_pending_returns_zero(): db.close.assert_called_once() +# --------------------------------------------------------------------------- +# #2603 — (address, city) pair grouping + filtered city_hint +# --------------------------------------------------------------------------- + + +def test_select_pending_addresses_returns_city_in_group(): + """The candidate SELECT reads `city` and carries it into the group.""" + db, _, _ = _make_db_mock( + address_rows=[ + {"address": "Нижний Тагил, Победы", "city": "Нижний Тагил", "deals_count": 3}, + {"address": "Тестовая, 1", "city": None, "deals_count": 1}, + ] + ) + + groups = _select_pending_addresses(db, limit=2000, stale_days=30) + + assert groups == [ + AddressGroup(address="Нижний Тагил, Победы", deals_count=3, city="Нижний Тагил"), + AddressGroup(address="Тестовая, 1", deals_count=1, city=None), + ] + sql_str = str(next(c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]))[0][0]) + assert "SELECT address, city, deals_count" in sql_str + + +async def test_run_backfill_passes_known_city_as_hint(): + groups = [AddressGroup(address="Победы, 30", deals_count=2, city="Нижний Тагил")] + db, coord_updates, _ = _make_db_mock(update_rowcount=2) + + with patch( + _GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim") + ) as mock_geo: + await _run_backfill(db, groups, batch="b", dry_run=False) + + mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил") + assert coord_updates[0]["city"] == "Нижний Тагил" + + +async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update(): + """Junk city → geocode WITHOUT a hint (old behaviour), yet the UPDATE is still + scoped to that exact group so it can't spill onto another city's rows.""" + groups = [AddressGroup(address="Бессонова, 11", deals_count=1, city="Бессонова")] + db, coord_updates, _ = _make_db_mock(update_rowcount=1) + + with patch( + _GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim") + ) as mock_geo: + await _run_backfill(db, groups, batch="b", dry_run=False) + + mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None) + assert coord_updates[0]["city"] == "Бессонова" + + +async def test_run_backfill_same_address_two_cities_independent(): + """Key #2603 scenario: identical address text in two cities → two geocode + calls with their own hints, and two UPDATEs scoped to their own city.""" + groups = [ + AddressGroup(address="Победы, 30", deals_count=4, city="Екатеринбург"), + AddressGroup(address="Победы, 30", deals_count=2, city="Нижний Тагил"), + ] + db, coord_updates, _ = _make_db_mock(update_rowcount=1) + + with patch( + _GEOCODE_PATH, + new_callable=AsyncMock, + side_effect=[_result("nominatim"), _result("nominatim", lat=57.910, lon=59.985)], + ) as mock_geo: + stats = await _run_backfill(db, groups, batch="b", dry_run=False) + + assert mock_geo.call_count == 2 + assert [c.kwargs["city_hint"] for c in mock_geo.call_args_list] == [ + "Екатеринбург", + "Нижний Тагил", + ] + assert stats.geocoded == 2 + assert [(u["addr"], u["city"]) for u in coord_updates] == [ + ("Победы, 30", "Екатеринбург"), + ("Победы, 30", "Нижний Тагил"), + ] + + +async def test_run_backfill_miss_marks_only_its_own_city(): + groups = [AddressGroup(address="Победы, 30", deals_count=1, city="Нижний Тагил")] + db, coord_updates, tried_updates = _make_db_mock() + + with patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=None): + await _run_backfill(db, groups, batch="b", dry_run=False) + + assert coord_updates == [] + assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}] + + # --------------------------------------------------------------------------- # Stats dataclass # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/services/payments/__init__.py b/tradein-mvp/backend/tests/services/payments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/tests/services/payments/test_tbank_client.py b/tradein-mvp/backend/tests/services/payments/test_tbank_client.py new file mode 100644 index 00000000..da052625 --- /dev/null +++ b/tradein-mvp/backend/tests/services/payments/test_tbank_client.py @@ -0,0 +1,293 @@ +"""Unit-тесты `app.services.payments.tbank_client.TBankClient`. + +NEVER calls real T-Bank API — только `httpx.MockTransport` (тот же паттерн, +что `tests/services/tgbot/test_client.py` и `tests/services/test_dadata.py`). +`asyncio.sleep` патчится no-op'ом, чтобы retry-тесты шли мгновенно +независимо от реального backoff. +""" + +from __future__ import annotations + +from collections.abc import Callable +from unittest import mock + +import httpx +import pytest + +from app.services.payments.tbank_client import TBankApiError, TBankClient + +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def _install_transport(handler: Callable[[httpx.Request], httpx.Response]) -> None: + transport = httpx.MockTransport(handler) + + def factory(*_: object, **__: object) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=transport) + + mock.patch("app.services.payments.tbank_client.httpx.AsyncClient", factory).start() + + +@pytest.fixture(autouse=True) +def _stop_patches_and_noop_sleep(): + sleep_patcher = mock.patch( + "app.services.payments.tbank_client.asyncio.sleep", return_value=None + ) + sleep_patcher.start() + yield + mock.patch.stopall() + + +def _client(**kwargs: object) -> TBankClient: + defaults: dict[str, object] = { + "terminal_key": "MerchantTerminalKey", + "password": "test-password", + "base_url": "https://rest-api-test.tinkoff.ru", + } + defaults.update(kwargs) + return TBankClient(**defaults) # type: ignore[arg-type] + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +async def test_init_payment_happy_path_returns_payment_url() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/Init") + return httpx.Response( + 200, + json={ + "Success": True, + "TerminalKey": "MerchantTerminalKey", + "Status": "NEW", + "PaymentId": "12345", + "OrderId": "order-1", + "Amount": 10000, + "PaymentURL": "https://securepay.tinkoff.ru/abc", + }, + ) + + _install_transport(handler) + client = _client() + result = await client.init_payment(order_id="order-1", amount_kopecks=10000) + + assert result["PaymentId"] == "12345" + assert result["PaymentURL"] == "https://securepay.tinkoff.ru/abc" + + +async def test_init_payment_signs_request_with_token() -> None: + """Запрос обязан содержать TerminalKey + Token в теле.""" + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + import json + + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + await client.init_payment(order_id="00000", amount_kopecks=19200) + + body = captured["body"] + assert isinstance(body, dict) + assert body["TerminalKey"] == "MerchantTerminalKey" + assert isinstance(body.get("Token"), str) and len(body["Token"]) == 64 + + +async def test_init_payment_receipt_excluded_from_signed_token_but_present_in_body() -> None: + """`Receipt` уходит в тело запроса, но не участвует в Token (см. token.py).""" + import json + + from app.services.payments.token import sign + + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + receipt = {"Email": "a@test.ru", "Taxation": "osn", "Items": []} + await client.init_payment(order_id="00000", amount_kopecks=19200, receipt=receipt) + + body = captured["body"] + assert isinstance(body, dict) + assert body["Receipt"] == receipt + + # Token, реально ушедший в теле, обязан совпадать с sign() тела БЕЗ Receipt + # (Receipt — dict, sign() сам его игнорирует) — пересчитаем и сверим. + without_token = {k: v for k, v in body.items() if k != "Token"} + assert body["Token"] == sign(without_token, "test-password") + + +async def test_get_state_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/GetState") + return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"}) + + _install_transport(handler) + client = _client() + result = await client.get_state(payment_id="12345") + assert result["Status"] == "CONFIRMED" + + +async def test_check_order_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/CheckOrder") + return httpx.Response(200, json={"Success": True, "Payments": []}) + + _install_transport(handler) + client = _client() + result = await client.check_order(order_id="order-1") + assert result["Payments"] == [] + + +async def test_confirm_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/Confirm") + return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"}) + + _install_transport(handler) + client = _client() + result = await client.confirm(payment_id="12345") + assert result["Status"] == "CONFIRMED" + + +async def test_cancel_posts_to_correct_path() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/v2/Cancel") + return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"}) + + _install_transport(handler) + client = _client() + result = await client.cancel(payment_id="12345", amount_kopecks=5000) + assert result["Status"] == "REFUNDED" + + +# ── retry policy ───────────────────────────────────────────────────────────── + + +async def test_retries_on_5xx_then_succeeds() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 3: + return httpx.Response(502, json={"ErrorCode": "502", "Message": "bad gw"}) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + result = await client.init_payment(order_id="1", amount_kopecks=100) + + assert result["PaymentId"] == "1" + assert calls["n"] == 3 + + +async def test_retries_on_network_error_then_succeeds() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] < 2: + raise httpx.ConnectError("connection refused", request=request) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client() + result = await client.init_payment(order_id="1", amount_kopecks=100) + + assert result["PaymentId"] == "1" + assert calls["n"] == 2 + + +async def test_gives_up_after_max_retries_on_persistent_5xx() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"ErrorCode": "500", "Message": "boom"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.get_state(payment_id="1") + + assert exc_info.value.error_code == "500" + + +async def test_does_not_retry_on_4xx() -> None: + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"}) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.init_payment(order_id="1", amount_kopecks=100) + + assert exc_info.value.error_code == "401" + assert calls["n"] == 1 # НЕ ретраится + + +async def test_business_failure_success_false_raises_without_retry() -> None: + """HTTP 200, но `Success: false` — бизнес-отказ банка, не сбой транспорта.""" + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response( + 200, + json={ + "Success": False, + "ErrorCode": "9999", + "Message": "Неверные параметры запроса", + "Details": "Amount must be positive", + }, + ) + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError) as exc_info: + await client.init_payment(order_id="1", amount_kopecks=100) + + assert exc_info.value.error_code == "9999" + assert exc_info.value.message == "Неверные параметры запроса" + assert calls["n"] == 1 # НЕ ретраится + + +async def test_malformed_json_response_raises_tbank_api_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"not json at all") + + _install_transport(handler) + client = _client() + + with pytest.raises(TBankApiError): + await client.get_state(payment_id="1") + + +# ── безопасность: пароль не попадает в тело запроса ───────────────────────── + + +async def test_password_never_sent_in_request_body() -> None: + import json + + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"Success": True, "PaymentId": "1"}) + + _install_transport(handler) + client = _client(password="super-secret-password") + await client.init_payment(order_id="1", amount_kopecks=100) + + body = captured["body"] + assert isinstance(body, dict) + assert "Password" not in body + assert "super-secret-password" not in json.dumps(body) diff --git a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py index 2204774a..fdd2d88b 100644 --- a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py +++ b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py @@ -176,24 +176,18 @@ def _nom_result(address: str) -> ReverseGeocodeResult: async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None: - """Cadastral returns address → Yandex/Nominatim never called.""" + """Cadastral returns address → Nominatim never called.""" db = MagicMock() with ( patch( "app.services.geocoder._cadastral_reverse_sync_full", return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052), ) as mock_cad, - patch("app.services.geocoder.settings") as mock_settings, - patch( - "app.services.geocoder._yandex_reverse", - new_callable=AsyncMock, - ) as mock_yandex, patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, ) as mock_nom, ): - mock_settings.yandex_geocoder_api_key = "fake-key" result = await reverse_geocode(56.838, 60.605, db=db) assert result is not None @@ -203,41 +197,6 @@ async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None: assert result.precision == "number" assert result.provider == "cadastral" mock_cad.assert_called_once_with(db, 56.838, 60.605) - mock_yandex.assert_not_called() - mock_nom.assert_not_called() - - -async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None: - """Cadastral None + Yandex key set → Yandex called, Nominatim skipped.""" - db = MagicMock() - yandex_result = ReverseGeocodeResult( - address="улица Малышева, 51, Екатеринбург", - snapped_lat=56.838004, - snapped_lon=60.586155, - precision="exact", - provider="yandex", - ) - with ( - patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), - patch("app.services.geocoder.settings") as mock_settings, - patch( - "app.services.geocoder._yandex_reverse", - new_callable=AsyncMock, - return_value=yandex_result, - ) as mock_yandex, - patch( - "app.services.geocoder._nominatim_reverse", - new_callable=AsyncMock, - ) as mock_nom, - ): - mock_settings.yandex_geocoder_api_key = "fake-key" - result = await reverse_geocode(56.838, 60.586, db=db) - - assert result is not None - assert result.provider == "yandex" - assert result.precision == "exact" - assert result.snapped_lat == 56.838004 - mock_yandex.assert_called_once() mock_nom.assert_not_called() @@ -263,23 +222,16 @@ async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() -> async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None: - """Cadastral + Yandex + Nominatim — все падают → returns None, no exception.""" + """Cadastral + Nominatim — все падают → returns None, no exception.""" db = MagicMock() with ( patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), - patch("app.services.geocoder.settings") as mock_settings, - patch( - "app.services.geocoder._yandex_reverse", - new_callable=AsyncMock, - side_effect=RuntimeError("Yandex 429 rate-limited"), - ), patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"), ), ): - mock_settings.yandex_geocoder_api_key = "fake-key" result = await reverse_geocode(56.838, 60.605, db=db) # Must return None, not raise @@ -287,19 +239,17 @@ async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None: async def test_reverse_geocode_without_db_skips_cadastral() -> None: - """When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim.""" + """When db=None, cadastral tier is skipped entirely → goes straight to Nominatim.""" with ( patch( "app.services.geocoder._cadastral_reverse_sync_full", ) as mock_cad, - patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_reverse", new_callable=AsyncMock, return_value=_nom_result("ул. Ленина, 1, Екатеринбург"), ), ): - mock_settings.yandex_geocoder_api_key = None result = await reverse_geocode(56.838, 60.605, db=None) mock_cad.assert_not_called() @@ -307,36 +257,11 @@ async def test_reverse_geocode_without_db_skips_cadastral() -> None: assert result.address == "ул. Ленина, 1, Екатеринбург" -async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None: - """Yandex raises → Nominatim still tried.""" - db = MagicMock() - with ( - patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), - patch("app.services.geocoder.settings") as mock_settings, - patch( - "app.services.geocoder._yandex_reverse", - new_callable=AsyncMock, - side_effect=RuntimeError("Yandex 500"), - ), - patch( - "app.services.geocoder._nominatim_reverse", - new_callable=AsyncMock, - return_value=_nom_result("ул. Дублёр, 1"), - ) as mock_nom, - ): - mock_settings.yandex_geocoder_api_key = "fake-key" - result = await reverse_geocode(56.838, 60.605, db=db) - - assert result is not None - assert result.provider == "nominatim" - mock_nom.assert_called_once() - - # ── geocode: cadastral as first tier ───────────────────────────────────────── -async def test_geocode_uses_cadastral_before_yandex() -> None: - """Cadastral FDW returns suggestion → geocode returns it without hitting Yandex.""" +async def test_geocode_uses_cadastral_before_nominatim() -> None: + """Cadastral FDW returns suggestion → geocode returns it without hitting Nominatim.""" db = MagicMock() # Cache miss db.execute.side_effect = [ @@ -362,7 +287,7 @@ async def test_geocode_uses_cadastral_before_yandex() -> None: return_value=[cad_suggestion], ) as mock_cad, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, + patch("app.services.geocoder._nominatim_lookup", new_callable=AsyncMock) as mock_nominatim, ): result = await geocode("Хохрякова 81", db) @@ -371,14 +296,14 @@ async def test_geocode_uses_cadastral_before_yandex() -> None: assert result.lon == 60.610 assert result.confidence == "exact" mock_cad.assert_called_once() - mock_yandex.assert_not_called() + mock_nominatim.assert_not_called() # ── suggest: cadastral as first tier ───────────────────────────────────────── -async def test_suggest_uses_cadastral_before_yandex() -> None: - """Cadastral FDW returns results → suggest returns them without hitting Yandex.""" +async def test_suggest_uses_cadastral_before_nominatim() -> None: + """Cadastral FDW returns results → suggest returns them without hitting Nominatim.""" db = MagicMock() cad_suggestions = [ GeocodeSuggestion( @@ -396,53 +321,20 @@ async def test_suggest_uses_cadastral_before_yandex() -> None: return_value=cad_suggestions, ) as mock_cad, patch( - "app.services.geocoder._yandex_suggest", + "app.services.geocoder._nominatim_suggest", new_callable=AsyncMock, - ) as mock_yandex, + ) as mock_nominatim, ): results = await suggest("Малышева", db=db, limit=8) assert len(results) == 1 assert results[0].full_address == "ул. Малышева, 30, Екатеринбург" mock_cad.assert_called_once() - mock_yandex.assert_not_called() + mock_nominatim.assert_not_called() -async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None: - """Cadastral returns [] → DaData not configured → falls through to Yandex.""" - db = MagicMock() - yandex_result = GeocodeSuggestion( - label="Малышева · Октябрьский", - full_address="улица Малышева, Екатеринбург", - lat=56.838, - lon=60.605, - kind="street", - ) - - with ( - patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), - patch("app.services.geocoder.settings") as mock_settings, - patch( - "app.services.geocoder._dadata_suggest", - new_callable=AsyncMock, - return_value=[], # DaData empty → falls through - ), - patch( - "app.services.geocoder._yandex_suggest", - new_callable=AsyncMock, - return_value=[yandex_result], - ) as mock_yandex, - ): - mock_settings.dadata_api_token = "fake-dadata-token" # gate to call _dadata_suggest - mock_settings.yandex_geocoder_api_key = "fake-key" - results = await suggest("Малышева", db=db, limit=8) - - assert len(results) == 1 - mock_yandex.assert_called_once() - - -async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None: - """Cadastral returns [] → DaData tier returns results → Yandex never called (PR Q2).""" +async def test_suggest_uses_dadata_before_nominatim_when_cadastral_empty() -> None: + """Cadastral returns [] → DaData tier returns results → Nominatim never called (PR Q2).""" db = MagicMock() dadata_result = GeocodeSuggestion( label="г Екатеринбург, ул Малышева, д 30", @@ -461,18 +353,17 @@ async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None: return_value=[dadata_result], ) as mock_dadata, patch( - "app.services.geocoder._yandex_suggest", + "app.services.geocoder._nominatim_suggest", new_callable=AsyncMock, - ) as mock_yandex, + ) as mock_nominatim, ): mock_settings.dadata_api_token = "fake-dadata-token" - mock_settings.yandex_geocoder_api_key = "fake-yandex-key" results = await suggest("Малышева", db=db, limit=8) assert len(results) == 1 assert results[0].lat == 56.838011 mock_dadata.assert_called_once() - mock_yandex.assert_not_called() + mock_nominatim.assert_not_called() # ── ensure_fdw_user_mapping: SQL injection / whitelist guards ───────────────── diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 63c0be4b..cee70c8c 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -138,6 +138,65 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None: assert "bbox_west" in sql_text and "bbox_east" in sql_text +# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ───────────── + + +def test_median_queries_filter_stale_listings() -> None: + """#2660: `is_active` на проде НЕ означает «живо» — деактиватор протухших покрывает не + все источники. Без предиката свежести медиана считалась по мертвецам: прод-замер по + пулу location_index (bbox ЕКБ) — 172 984 ₽/м² по всему активному пулу против + 163 363 по свежим. Оба запроса (локальный и общегородской) должны фильтровать + симметрично, иначе индекс = отношение двух разных популяций.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text) + assert "scraped_at > NOW() - (:fresh_days || ' days')::interval" in sql_text + + +def test_median_queries_exclude_novostroyki() -> None: + """Сегментный гард #1186 — тот же канонический предикат, что в _COMMON_WHERE + эстиматора. NULL = legacy вторичка до м.011 и должен остаться в пуле.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text) + assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql_text + + +def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None: + """Второе определение свежести разъедется с эстиматором при первой перекалибровке — + витрина начнёт показывать пул, отличный от того, на котором считается цена. + + Проверяем ИСХОДНИК, а не равенство значений: CPython кэширует малые int (−5…256), + поэтому `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` проходит и для + скопированного литерала `LISTINGS_FRESH_DAYS = 14` — то есть стережёт не то, что + обещает. Отсутствие присваивания в модуле = константа может быть только импортом. + """ + import inspect + + from app.services import estimator + + assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource( + lc + ), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS + + +def test_both_median_queries_bind_the_same_freshness_window() -> None: + """Свежесть должна реально долетать в оба запроса bind-параметром (а не остаться + только в тексте SQL) и быть ОДИНАКОВОЙ — иначе локальная и городская медианы + считаются по разным окнам, и индекс — отношение несопоставимых величин.""" + db = _FakeDB( + [ + _FakeResult(scalar_value=0), # poi mirror empty + _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide + _FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0] + ] + ) + lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) + citywide_params = db.executed[1][1] + local_params = db.executed[2][1] + assert citywide_params["fresh_days"] == lc.LISTINGS_FRESH_DAYS + assert local_params["fresh_days"] == lc.LISTINGS_FRESH_DAYS + + # ── _fetch_nearby_poi (qualitative "что рядом" list) ───────────────────────── diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index 5a39acb2..ca25dc81 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -1,4 +1,4 @@ -"""Offline-тесты пула прокси (#2162). +"""Offline-тесты пула прокси (#2162, #2600). Покрытие БЕЗ live-сети/БД: stateful FakeSession эмулирует таблицу scrape_proxies и интерпретирует SQL по ключевым фрагментам, так что acquire/release/mark_health/ @@ -8,11 +8,37 @@ reap_stale_leases проверяются по фактическому изме - два acquire подряд → РАЗНЫЕ прокси (первый лизнут → выпал из выборки второго). - release освобождает (leased_by → NULL), прокси снова acquire-абелен. - mark_health fail → инкремент consecutive_fails, авто-disable при DISABLE_THRESHOLD. - - mark_health ok → сброс fails + exit_ip/latency. + - mark_health ok → сброс fails + exit_ip/latency + enabled=true (реанимация). - reap_stale_leases освобождает старый lease, свежий не трогает. + - touch (#2164 sticky-session fix, 2026-08): heartbeat продлевает leased_at активного + lease, no-op на свободном узле; повторный touch перед reap не даёт reap_stale_leases + отобрать многочасовую browser-сессию, отсутствие touch — реапится как раньше. - affinity-фильтр: acquire('avito') не берёт cian-only прокси. - acquire пропускает disabled и «нездоровые» (fails >= MAX_CONSECUTIVE_FAILS). + - acquire без своих/any свободных → берёт свободный чужой affinity (fallback, #2600 п.3). - run_proxy_healthcheck: reap + проба каждого enabled + mark_health (проба замокана). + - run_proxy_healthcheck: disabled-узлы — самовосстановление (#2600 п.1): + * успешная проба выключенного узла возвращает его в строй + revived++; + * недавно проверенный выключенный узел повторно не проверяется (не долбим провайдера). + - mark_health / disabled_reason (#2610 — ручное vs авто-выключение): + * авто-выключенный узел (disabled_reason IS NULL) по-прежнему воскресает + успешной пробой — поведение #2609 не сломано; + * ручно-выключенный узел (disabled_reason НЕ NULL) НЕ воскресает даже при + ok=True, и это логируется (WARNING); + * run_proxy_healthcheck не считает ручно-выключенный узел в revived. + - бан по паре «узел × источник» (#2600 п.2, scrape_proxy_source_bans): + * mark_banned пишет строку бана и НЕ выключает узел глобально; + * повторный бан той же пары эскалирует срок (ban_count растёт); + * acquire не выдаёт узел с активным баном по ЭТОМУ source, но выдаёт по ДРУГОМУ + (суть задачи: Авито забанил — Яндекс продолжает ходить через тот же узел); + * истёкший бан снова не мешает выдаче; + * защита последнего узла: бан НЕ записывается, если у acquire(source) не + останется кандидатов; + * run_proxy_healthcheck сносит бан-строки, истёкшие дольше SOURCE_BAN_PURGE_DAYS, + и НЕ трогает истёкшие недавно (в них живёт ban_count для эскалации); + * backup выделенной affinity засчитывается, только если он сам не забанен своим + источником (иначе fallback уводил бы последний рабочий узел, deep-review); + * clear_source_bans снимает баны узла (все / один source) и обнуляет эскалацию. """ from __future__ import annotations @@ -29,13 +55,33 @@ import pytest from app.services import proxy_pool from app.services.proxy_pool import ( DISABLE_THRESHOLD, + DISABLED_RECHECK_MINUTES, MAX_CONSECUTIVE_FAILS, + SOURCE_BAN_BASE_HOURS, + SOURCE_BAN_MAX_HOURS, + SOURCE_BAN_PURGE_DAYS, + STALE_LEASE_MINUTES, acquire, mark_health, reap_stale_leases, release, ) +# mark_banned() не существовал до #2600 п.1 (2026-08) — тот же паттерн отложенного +# импорта, что touch() выше: AttributeError падает ТОЛЬКО в этих тестах, не роняя +# коллекцию всего файла на pre-fix коде. +try: + from app.services.proxy_pool import mark_banned +except ImportError: # pragma: no cover — pre-fix guard, см. комментарий выше + mark_banned = None # type: ignore[assignment] + +# touch() не существовал до sticky-session фикса (#2164, 2026-08) — тесты ниже +# обращаются к нему через `proxy_pool.touch(...)` (module attribute), а не прямым +# top-level импортом, чтобы отсутствие функции в pre-fix коде падало ТОЛЬКО в этих +# тестах (AttributeError — capability реально не существовала), а не ронуло +# коллекцию всего файла и не маскировало value-based тесты acquire/release/ +# mark_health/reap выше, которые этим фиксом не менялись. + # ── stateful fake session ──────────────────────────────────────────────────── @@ -58,30 +104,90 @@ class _FakeResult: class FakeSession: - """Эмуляция Session поверх in-memory списка scrape_proxies-строк.""" + """Эмуляция Session поверх in-memory списков scrape_proxies + scrape_proxy_source_bans.""" - def __init__(self, rows: list[dict[str, Any]]): + def __init__(self, rows: list[dict[str, Any]], bans: list[dict[str, Any]] | None = None): self.rows = rows + # #2600 п.2: строки scrape_proxy_source_bans (proxy_id, source, banned_until, + # ban_count) — бан теперь свойство ПАРЫ «узел × источник», не узла. + self.bans: list[dict[str, Any]] = bans or [] + # deep-review fix 2 (#2600): вызовы pg_advisory_xact_lock — для теста + # "лок реально берётся" (по SQL-подстроке, race саму по себе юнитом не + # проверить — фейксессия однопоточна). + self.advisory_lock_calls: list[int] = [] # helpers def _by_id(self, pid: int) -> dict[str, Any] | None: return next((r for r in self.rows if r["id"] == pid), None) + def _ban(self, pid: int, source: str) -> dict[str, Any] | None: + return next((b for b in self.bans if b["proxy_id"] == pid and b["source"] == source), None) + + def _has_active_ban(self, pid: int, source: str) -> bool: + ban = self._ban(pid, source) + return ban is not None and ban["banned_until"] > datetime.now(UTC) + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) p = params or {} - if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT - provider = p["provider"] + if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT (primary affinity-scoped or fallback) max_fails = p["max_fails"] - cands = [ - r - for r in self.rows - if r["enabled"] - and r["consecutive_fails"] < max_fails - and r["provider_affinity"] in (provider, "any") - and r["leased_by"] is None - ] + provider = p["provider"] + # #2600 п.2: узел с активным баном по ЭТОМУ source не выдаётся. Как и с + # protects_last_node ниже — фильтруем ТОЛЬКО если сам SQL реально содержит + # NOT EXISTS по scrape_proxy_source_bans, иначе мок реализовывал бы логику + # независимо от проверяемого кода и не отличил бы старый запрос от нового. + filters_bans = "scrape_proxy_source_bans" in sql + + def _not_banned(row: dict[str, Any]) -> bool: + return not filters_bans or not self._has_active_ban(row["id"], provider) + + if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any' + cands = [ + r + for r in self.rows + if r["enabled"] + and r["consecutive_fails"] < max_fails + and r["provider_affinity"] in (provider, "any") + and r["leased_by"] is None + and _not_banned(r) + ] + else: # fallback: любая affinity, но не последний узел выделенной affinity + # (domclick и т.п. — #2600 review). ВАЖНО: применяем эту фильтрацию, + # только если сама SQL реально содержит защиту (EXISTS-подзапрос) — + # иначе мок реализовывал бы бизнес-логику независимо от проверяемого + # кода и не смог бы отличить старый (незащищённый) fallback-запрос от + # нового. Тот же класс бага, что был с "enabled" в mark_health-моке. + protects_last_node = "EXISTS" in sql + # backup засчитывается, только если он ПРИГОДЕН для своей affinity — + # тоже гейтим по подстроке (b2-подзапрос), иначе мок «чинил» бы + # незащищённый SQL сам. + backup_must_be_usable = "b2.banned_until > now()" in sql + + def _has_backup(row: dict[str, Any]) -> bool: + if row["provider_affinity"] == "any": + return True + return any( + other["provider_affinity"] == row["provider_affinity"] + and other["enabled"] + and other["id"] != row["id"] + and not ( + backup_must_be_usable + and self._has_active_ban(other["id"], other["provider_affinity"]) + ) + for other in self.rows + ) + + cands = [ + r + for r in self.rows + if r["enabled"] + and r["consecutive_fails"] < max_fails + and r["leased_by"] is None + and _not_banned(r) + and (not protects_last_node or _has_backup(r)) + ] # ORDER BY last_ok_at NULLS LAST, id cands.sort( key=lambda r: ( @@ -120,6 +226,12 @@ class FakeSession: row["leased_at"] = None return _FakeResult([]) + if "SET leased_at = now()" in sql: # touch heartbeat (#2164 sticky-session fix) + row = self._by_id(p["id"]) + if row is not None and row["leased_by"] is not None: + row["leased_at"] = datetime.now(UTC) + return _FakeResult([]) + if "SET consecutive_fails = 0" in sql: # mark_health ok row = self._by_id(p["id"]) if row is not None: @@ -127,20 +239,129 @@ class FakeSession: row["exit_ip"] = p["exit_ip"] row["latency_ms"] = p["latency_ms"] row["last_ok_at"] = datetime.now(UTC) + row["last_check_at"] = datetime.now(UTC) + # Ручное выключение (#2610): реальный SQL реанимирует (enabled=true) + # ТОЛЬКО если disabled_reason IS NULL — мок обязан честно это + # воспроизвести, иначе тест не поймает регресс "снова безусловно + # enabled=true" (тот же класс бага, что был с "enabled" in sql до + # #2600 review). + if "disabled_reason IS NULL" in sql: + if row.get("disabled_reason") is None: + row["enabled"] = True + elif "enabled" in sql: + row["enabled"] = True + if "RETURNING disabled_reason" in sql: + return _FakeResult([{"disabled_reason": row.get("disabled_reason")}]) return _FakeResult([]) if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail row = self._by_id(p["id"]) if row is not None: row["consecutive_fails"] += 1 + row["last_check_at"] = datetime.now(UTC) if row["consecutive_fails"] >= p["disable_threshold"]: row["enabled"] = False return _FakeResult([]) - if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT - rows = sorted((r for r in self.rows if r["enabled"]), key=lambda r: r["id"]) + if "WHERE enabled" in sql and "ORDER BY id" in sql: # healthcheck SELECT (#2600 п.1) + recheck_minutes = p["disabled_recheck_minutes"] + cutoff = datetime.now(UTC) - timedelta(minutes=recheck_minutes) + cands = [ + r + for r in self.rows + if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff + ] + rows = sorted(cands, key=lambda r: r["id"]) return _FakeResult([dict(r) for r in rows]) + if "pg_advisory_xact_lock" in sql: # deep-review fix 2 (#2600) — mark_banned serialize + self.advisory_lock_calls.append(p["key"]) + return _FakeResult([]) + + if "INSERT INTO scrape_proxy_source_bans" in sql: # mark_banned UPSERT (#2600 п.2) + proxy_id = p["proxy_id"] + source = p["source"] + max_fails = p["max_fails"] + if self._by_id(proxy_id) is None: + return _FakeResult([]) # узла нет — no-op + + # Оба ban-предиката гейтим по подстрокам самого SQL (как в acquire-ветке): + # иначе мок реализовывал бы защиту сам и тест оставался бы зелёным даже + # после удаления NOT EXISTS из боевого запроса. + filters_bans = "b.banned_until > now()" in sql + backup_must_be_usable = "b2.banned_until > now()" in sql + + def _is_candidate(sp: dict[str, Any]) -> bool: + if not (sp["enabled"] and sp["consecutive_fails"] < max_fails): + return False + if filters_bans and self._has_active_ban(sp["id"], source): + return False # уже забанен этим же источником — не кандидат + if sp["provider_affinity"] in (source, "any"): + return True + # fallback-safe: другой ПРИГОДНЫЙ узел ТОЙ ЖЕ affinity (банимый узел + # остаётся enabled и тоже считается — бан теперь per-source; а вот + # забаненный своим же источником backup'ом не считается). + return any( + other["provider_affinity"] == sp["provider_affinity"] + and other["enabled"] + and other["id"] != sp["id"] + and not ( + backup_must_be_usable + and self._has_active_ban(other["id"], other["provider_affinity"]) + ) + for other in self.rows + ) + + still_available = any(r["id"] != proxy_id and _is_candidate(r) for r in self.rows) + if not still_available: + return _FakeResult([]) # protected — последний узел для source, бан не пишем + + now = datetime.now(UTC) + ban = self._ban(proxy_id, source) + if ban is None: + ban = { + "proxy_id": proxy_id, + "source": source, + "ban_count": 1, + "banned_until": now + timedelta(hours=p["base_hours"]), + "reason": p["reason"], + } + self.bans.append(ban) + else: + # эскалация: срок = base * 2^(новый ban_count - 1), потолок max_hours + ban["ban_count"] += 1 + hours = min(p["base_hours"] * 2 ** (ban["ban_count"] - 1), p["max_hours"]) + ban["banned_until"] = now + timedelta(hours=hours) + ban["reason"] = p["reason"] + return _FakeResult( + [{"ban_count": ban["ban_count"], "banned_until": ban["banned_until"]}] + ) + + if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql: + # clear_source_bans: снять баны узла (все либо один source), #2600 п.2 + cleared = [ + b + for b in self.bans + if b["proxy_id"] == p["proxy_id"] + and (p["source"] is None or b["source"] == p["source"]) + ] + self.bans = [b for b in self.bans if b not in cleared] + return _FakeResult([{"source": b["source"]} for b in cleared]) + + if "DELETE FROM scrape_proxy_source_bans" in sql: # purge истёкших (#2600 п.2) + cutoff = datetime.now(UTC) - timedelta(days=p["days"]) + purged = [{"proxy_id": b["proxy_id"]} for b in self.bans if b["banned_until"] < cutoff] + self.bans = [b for b in self.bans if b["banned_until"] >= cutoff] + return _FakeResult(purged) + + if "SELECT enabled, disabled_reason FROM scrape_proxies" in sql: # mark_banned diag read + row = self._by_id(p["id"]) + if row is None: + return _FakeResult([]) + return _FakeResult( + [{"enabled": row["enabled"], "disabled_reason": row["disabled_reason"]}] + ) + raise AssertionError(f"unhandled SQL: {sql}") def commit(self) -> None: @@ -159,8 +380,10 @@ def _proxy( leased_by: int | None = None, leased_at: datetime | None = None, last_ok_at: datetime | None = None, + last_check_at: datetime | None = None, kind: str = "http", rotate_url: str | None = None, + disabled_reason: str | None = None, ) -> dict[str, Any]: return { "id": pid, @@ -169,10 +392,12 @@ def _proxy( "rotate_url": rotate_url, "provider_affinity": affinity, "enabled": enabled, + "disabled_reason": disabled_reason, "consecutive_fails": fails, "leased_by": leased_by, "leased_at": leased_at, "last_ok_at": last_ok_at, + "last_check_at": last_check_at, "exit_ip": None, "latency_ms": None, } @@ -209,11 +434,6 @@ def test_acquire_empty_pool_returns_none() -> None: assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] -def test_acquire_affinity_filter_excludes_other_provider() -> None: - db = FakeSession([_proxy(1, affinity="cian")]) - assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] - - def test_acquire_skips_disabled() -> None: db = FakeSession([_proxy(1, affinity="avito", enabled=False)]) assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] @@ -231,6 +451,89 @@ def test_acquire_without_run_id_uses_marker() -> None: assert db._by_id(1)["leased_by"] == proxy_pool.NON_RUN_LEASE_MARKER +# ── acquire: fallback affinity (#2600 п.3 — не морить источник голодом) ──────── + + +def test_acquire_prefers_own_affinity_when_available() -> None: + """Своих (affinity=avito) хватает — приоритет не сломан, чужой (cian) не берём.""" + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="cian")]) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + + +def test_acquire_falls_back_to_other_affinity_when_no_own_free() -> None: + """Свободных avito/any нет, но есть свободный здоровый cian с бэкапом → fallback, а не None. + + Два cian-узла — забрать один через fallback безопасно: у cian остаётся другой + enabled-узел (protection на "последний узел affinity" не срабатывает). + """ + db = FakeSession([_proxy(1, affinity="cian"), _proxy(2, affinity="cian")]) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + assert db._by_id(1)["leased_by"] == 1 + + +def test_acquire_no_fallback_when_nothing_free_at_all() -> None: + """Fallback не выдумывает прокси из воздуха — если свободных нет вообще, None.""" + db = FakeSession([_proxy(1, affinity="cian", leased_by=99)]) # занят + assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + + +# ── acquire: fallback НЕ забирает последний узел выделенной affinity (review #2609) ── +# +# domclick — ровно один узел (прод scrape_proxies.id=1), намеренно вырезанный из общего +# пула через provider_affinity='domclick': QRATOR банит всё, кроме этого одного чистого +# residential-адреса (см. 173_scrape_proxies_add_domclick_affinity.sql). Если fallback +# заберёт его под avito/cian/yandex — domclick (сейчас исправно собирает: 6501 активных +# объявлений, 368/сутки) останется без прокси вообще. Починка одного источника ценой +# полной поломки другого недопустима. + + +def test_acquire_fallback_protects_last_node_of_dedicated_affinity() -> None: + """Единственный enabled-узел domclick НЕ отдаётся avito через fallback — None.""" + db = FakeSession([_proxy(1, affinity="domclick")]) + assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + assert db._by_id(1)["leased_by"] is None # узел не тронут + + +def test_acquire_fallback_allows_when_dedicated_affinity_has_backup() -> None: + """Второй enabled-узел domclick есть → fallback как и раньше отдаёт свободный.""" + db = FakeSession([_proxy(1, affinity="domclick"), _proxy(2, affinity="domclick")]) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + assert db._by_id(2)["leased_by"] is None # у domclick остался живой запасной узел + + +def test_acquire_fallback_backup_must_be_usable_for_its_own_source() -> None: + """deep-review #2600 п.2: «backup» — это ПРИГОДНЫЙ узел, а не просто enabled. + + Два узла domclick: node1 забанен САМИМ domclick'ом (законно — node2 тогда был жив) и + сейчас занят чужим прогоном, node2 свободен. Для domclick node2 — последний рабочий. + Раньше EXISTS видел node1 как backup (он ведь enabled) и разрешал fallback увести + node2 под avito — domclick оставался бы без прокси вообще при двух включённых узлах. + """ + db = FakeSession( + [_proxy(1, affinity="domclick", leased_by=99), _proxy(2, affinity="domclick")], + bans=[ + { + "proxy_id": 1, + "source": "domclick", + "ban_count": 1, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS), + "reason": "banned:domclick", + } + ], + ) + assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + assert db._by_id(2)["leased_by"] is None # последний рабочий узел domclick не тронут + # сам domclick при этом обслуживается: node2 свободен и не забанен + lease = acquire(db, "domclick", run_id=2) # type: ignore[arg-type] + assert lease is not None and lease.id == 2 + + # ── release ────────────────────────────────────────────────────────────────── @@ -271,6 +574,46 @@ def test_mark_health_ok_resets_and_records() -> None: assert row["last_ok_at"] is not None +def test_mark_health_ok_revives_disabled_proxy() -> None: + """Успешная проба реанимирует выключенный узел (#2600 п.1) — enabled=true, fails=0.""" + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD)]) + mark_health(db, 1, ok=True) # type: ignore[arg-type] + row = db._by_id(1) + assert row["enabled"] is True + assert row["consecutive_fails"] == 0 + + +# ── mark_health / disabled_reason (#2610 — ручное vs авто-выключение) ────────── +# +# red/green контракт issue: (a) авто-выключенный узел воскресает по успешной пробе +# (#2609 не сломан — дублирует test_mark_health_ok_revives_disabled_proxy выше, но +# явно рядом с (b)/(c) для контраста), (b) ручно-выключенный НЕ воскресает + лог, +# (c) ручное включение сбрасывает флаг → узел снова авто-восстанавливаем (уровень +# admin API — см. tests/test_admin_proxies.py, mark_health сам флаг не трогает). + + +def test_mark_health_ok_revives_auto_disabled_proxy_a() -> None: + """(a) Авто-выключенный (disabled_reason=NULL) воскресает — поведение #2609 сохранено.""" + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, disabled_reason=None)]) + mark_health(db, 1, ok=True) # type: ignore[arg-type] + row = db._by_id(1) + assert row["enabled"] is True + assert row["consecutive_fails"] == 0 + + +def test_mark_health_ok_does_not_revive_manually_disabled_proxy_b( + caplog: pytest.LogCaptureFixture, +) -> None: + """(b) Ручно-выключенный (disabled_reason НЕ NULL) НЕ воскресает — и это логируется.""" + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, disabled_reason="manual")]) + with caplog.at_level("WARNING"): + mark_health(db, 1, ok=True) # type: ignore[arg-type] + row = db._by_id(1) + assert row["enabled"] is False # НЕ реанимирован, несмотря на ok=True + assert row["consecutive_fails"] == 0 # fails всё равно сбрасывается пробой + assert any("manually disabled" in rec.message for rec in caplog.records) + + # ── reap_stale_leases ──────────────────────────────────────────────────────── @@ -289,33 +632,635 @@ def test_reap_frees_stale_lease_keeps_fresh() -> None: assert db._by_id(2)["leased_by"] == 51 # свежий не тронут +# ── touch (heartbeat) — sticky browser-session lease fix (#2164, 2026-08) ───── +# +# Живая регрессия: BrowserFetcher раньше acquire/release-ил прокси НА КАЖДЫЙ /fetch — +# при N>=2 живых узлах пула это гарантированно меняло прокси между соседними запросами +# и гоняло camoufox relaunch на каждый /fetch (17 relaunch'ей за 15 минут в проде). +# Фикс — один lease на весь жизненный цикл BrowserFetcher (сессия, часы). Коллизия: +# reap_stale_leases отбирает lease старше STALE_LEASE_MINUTES=30, а прогоны бывают +# ДОЛЬШЕ (полная загрузка Циана — часами). touch() — heartbeat, которым BrowserFetcher +# продлевает leased_at на каждый /fetch, пока сессия жива; без touch (мёртвый/зависший +# run) lease по-прежнему реапится штатно — semantics краш-recovery не ослаблена. + + +def test_touch_refreshes_leased_at_of_active_lease() -> None: + old = datetime.now(UTC) - timedelta(minutes=45) + db = FakeSession([_proxy(1, leased_by=100, leased_at=old)]) + proxy_pool.touch(db, 1) # type: ignore[arg-type] + assert db._by_id(1)["leased_at"] > old + + +def test_touch_noop_when_not_leased() -> None: + """Прокси свободен (leased_by=NULL) — touch не должен «арендовывать» его тайком.""" + db = FakeSession([_proxy(1, leased_by=None, leased_at=None)]) + proxy_pool.touch(db, 1) # type: ignore[arg-type] + row = db._by_id(1) + assert row["leased_by"] is None + assert row["leased_at"] is None # touch не проставил leased_at свободному узлу + + +def test_touch_prevents_reap_of_long_running_session() -> None: + """КЛЮЧЕВАЯ коллизия из PR: lease взят 45 минут назад (> STALE_LEASE_MINUTES=30 — + reap_stale_leases его бы отобрал по «возрасту acquire»), но BrowserFetcher вызывал + touch() на каждый /fetch все эти 45 минут — leased_at всегда свежий. reap НЕ должен + освободить активную многочасовую сессию.""" + old_acquire = datetime.now(UTC) - timedelta(minutes=45) + db = FakeSession([_proxy(1, leased_by=proxy_pool.NON_RUN_LEASE_MARKER, leased_at=old_acquire)]) + + # heartbeat только что прошёл (BrowserFetcher вызвал touch на последнем /fetch) + proxy_pool.touch(db, 1) # type: ignore[arg-type] + + freed = reap_stale_leases(db, older_than_minutes=STALE_LEASE_MINUTES) # type: ignore[arg-type] + + assert freed == 0 + assert db._by_id(1)["leased_by"] == proxy_pool.NON_RUN_LEASE_MARKER # НЕ отобран + + +def test_touch_absence_still_reaps_dead_session() -> None: + """Без heartbeat'а (упавший/зависший прогон, ни разу не сходивший в touch) reaper + по-прежнему освобождает протухший lease — семантика краш-recovery не ослаблена + фиксом (мы НЕ увеличивали STALE_LEASE_MINUTES, чтобы не притупить эту защиту).""" + old_acquire = datetime.now(UTC) - timedelta(minutes=45) + db = FakeSession([_proxy(1, leased_by=proxy_pool.NON_RUN_LEASE_MARKER, leased_at=old_acquire)]) + + freed = reap_stale_leases(db, older_than_minutes=STALE_LEASE_MINUTES) # type: ignore[arg-type] + + assert freed == 1 + assert db._by_id(1)["leased_by"] is None # мёртвая сессия реапится как раньше + + # ── run_proxy_healthcheck ──────────────────────────────────────────────────── async def test_healthcheck_probes_enabled_and_marks_health( monkeypatch: pytest.MonkeyPatch, ) -> None: + recently_checked = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES db = FakeSession( [ _proxy(1, fails=2), - _proxy(2, enabled=False), # disabled — не проверяется + # disabled, recheck ещё не наступил (недавно проверен) — не проверяется в этот прогон + _proxy(2, enabled=False, last_check_at=recently_checked), _proxy(3, fails=0), ] ) - async def _fake_probe(url: str) -> tuple[bool, str | None, int | None]: + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: # прокси 1 «жив», прокси 3 «мёртв» if "h1:" in url: - return True, "9.9.9.9", 42 - return False, None, None + return True, "9.9.9.9", 42, None + return False, None, None, "other" monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert counters["checked"] == 2 # только enabled (1 и 3) + assert counters["checked"] == 2 # только enabled (1 и 3), disabled recheck не наступил assert counters["ok"] == 1 assert counters["failed"] == 1 + assert counters["revived"] == 0 assert db._by_id(1)["consecutive_fails"] == 0 # ok → сброс assert db._by_id(1)["exit_ip"] == "9.9.9.9" assert db._by_id(3)["consecutive_fails"] == 1 # fail → инкремент + + +# ── run_proxy_healthcheck: self-healing disabled-узлов (#2600 п.1) ───────────── + + +async def test_healthcheck_revives_disabled_proxy_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Выключенный узел с успешной пробой возвращается в строй, revived++.""" + stale_check = datetime.now(UTC) - timedelta(minutes=DISABLED_RECHECK_MINUTES + 5) + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=stale_check)]) + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "5.5.5.5", 30, None + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 1 + assert counters["ok"] == 1 + assert counters["revived"] == 1 + row = db._by_id(1) + assert row["enabled"] is True + assert row["consecutive_fails"] == 0 + + +async def test_healthcheck_does_not_revive_manually_disabled_proxy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """(b)/(с) на уровне healthcheck: ручно-выключенный узел пробуется (recheck наступил), + но остаётся disabled даже при успешной пробе — revived НЕ растёт (#2610).""" + stale_check = datetime.now(UTC) - timedelta(minutes=DISABLED_RECHECK_MINUTES + 5) + db = FakeSession( + [ + _proxy( + 1, + enabled=False, + fails=DISABLE_THRESHOLD, + last_check_at=stale_check, + disabled_reason="manual", + ) + ] + ) + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "5.5.5.5", 30, None + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 1 + assert counters["ok"] == 1 + assert counters["revived"] == 0 # ручной флаг — mark_health не воскресил + row = db._by_id(1) + assert row["enabled"] is False # остался выключенным + assert row["consecutive_fails"] == 0 # проба всё равно сбросила счётчик fails + + +async def test_healthcheck_skips_recently_checked_disabled_proxy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Выключенный узел, проверенный недавно, повторно не проверяется в этот прогон.""" + fresh_check = datetime.now(UTC) - timedelta(minutes=5) # < DISABLED_RECHECK_MINUTES + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=fresh_check)]) + probed: list[str] = [] + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + probed.append(url) # не должно вызваться + return True, "5.5.5.5", 30, None + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 0 + assert counters["revived"] == 0 + assert probed == [] # провайдер не долбим каждый тик + assert db._by_id(1)["enabled"] is False # остался выключенным + + +async def test_healthcheck_checks_disabled_proxy_never_checked_before( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Выключенный узел без last_check_at (никогда не проверялся) — проверяется сразу.""" + db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, last_check_at=None)]) + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + return False, None, None, "timeout" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["checked"] == 1 + assert counters["revived"] == 0 # неуспех — не реанимируем + assert db._by_id(1)["enabled"] is False + + +# ── mark_banned (#2600 п.2 — бан по паре «узел × источник») ──────────────────── +# +# Red/green контракт issue: (a) распознанный бан → строка в scrape_proxy_source_bans, +# узел НЕ выключен глобально (в п.1 было enabled=false — площадка забанила IP, а не +# сломала прокси; узел обязан остаться живым для остальных источников); (b) повторный +# бан той же пары эскалирует срок; (c) последний достижимый для source узел НЕ банится +# (только лог); (d) сетевой сбой по-прежнему идёт через mark_health(ok=False), НЕ через +# mark_banned (проверяется на уровне browser_fetcher/curl_proxy_url тестов — здесь +# mark_banned сам по себе не участвует в различении причин, это забота caller'а). + + +def test_mark_banned_writes_source_ban_row() -> None: + """Бан пишется в scrape_proxy_source_bans: source, ban_count=1, срок = base-часы.""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + ban = db._ban(1, "avito") + assert ban is not None + assert ban["ban_count"] == 1 + assert ban["reason"] == "banned:avito" + expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS) + assert abs((ban["banned_until"] - expected).total_seconds()) < 60 + + +def test_mark_banned_does_not_disable_node_globally() -> None: + """ГЛАВНОЕ отличие от #2600 п.1: узел остаётся enabled и без disabled_reason — + глобальное выключение теперь только за оператором/авто-disable'ом (#2610).""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + row = db._by_id(1) + assert row["enabled"] is True + assert row["disabled_reason"] is None + + +def test_mark_banned_repeat_escalates_ban_count_and_duration() -> None: + """Повторный бан той же пары: ban_count растёт, срок удваивается (base * 2^(N-1)).""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + first_until = db._ban(1, "avito")["banned_until"] + + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + + ban = db._ban(1, "avito") + assert ban["ban_count"] == 2 + assert len(db.bans) == 1 # PK (proxy_id, source) — дубля нет + expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS * 2) + assert abs((ban["banned_until"] - expected).total_seconds()) < 60 + assert ban["banned_until"] > first_until + + +def test_mark_banned_escalation_capped_at_max_hours() -> None: + """Эскалация упирается в SOURCE_BAN_MAX_HOURS, а не растёт до бесконечности.""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="any")]) + for _ in range(8): + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + ban = db._ban(1, "avito") + expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_MAX_HOURS) + assert abs((ban["banned_until"] - expected).total_seconds()) < 60 + + +def test_mark_banned_different_sources_are_independent_rows() -> None: + """Бан Авито и бан Циана на одном узле — две независимые строки, не перезапись.""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + mark_banned(db, 1, source="cian") # type: ignore[arg-type] + assert {b["source"] for b in db.bans} == {"avito", "cian"} + assert all(b["ban_count"] == 1 for b in db.bans) + + +def test_mark_banned_unknown_id_is_noop() -> None: + """Несуществующий proxy_id — бан не пишется, падать не должно (best-effort caller).""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="any")]) + mark_banned(db, 999, source="avito") # type: ignore[arg-type] + assert db.bans == [] + + +def test_mark_banned_protects_last_live_node_own_affinity() -> None: + """Единственный узел avito, других (свободных/'any') нет вообще — бан НЕ пишется, + только лог (issue #2600: бан не должен обрушить единственный источник целиком — + ходить через забаненный узел лучше, чем не ходить вообще).""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db.bans == [] + assert db._by_id(1)["enabled"] is True # и глобально не тронут + + +def test_mark_banned_protects_last_live_node_logs_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito")]) + with caplog.at_level("WARNING"): + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert any("бан не записан" in rec.message for rec in caplog.records) + + +def test_mark_banned_protection_counts_active_bans_of_other_nodes() -> None: + """Второй узел формально жив, но уже забанен ЭТИМ ЖЕ источником — кандидатом для + source он не является, значит бан первого узла оставил бы avito без прокси вообще. + Защита обязана сработать (иначе оба узла разом выпадут для avito).""" + assert mark_banned is not None + db = FakeSession( + [_proxy(1, affinity="avito"), _proxy(2, affinity="any")], + bans=[ + { + "proxy_id": 2, + "source": "avito", + "ban_count": 1, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS), + "reason": "banned:avito", + } + ], + ) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db._ban(1, "avito") is None # бан первого узла не записан + + +def test_mark_banned_writes_when_any_affinity_backup_exists() -> None: + """Забанен единственный avito-специфичный узел, но есть 'any' — 'any' закрывает + availability для avito (та же семантика, что acquire()'s primary IN (provider, + 'any')) → бан записывается безопасно.""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db._ban(1, "avito") is not None + + +# ── mark_banned: affinity-aware last-node (orchestrator follow-up, свежий прод-факт) ── +# +# COUNT(*) WHERE enabled наивно посчитал бы "живых узлов много" даже когда +# конкретно для `source` не осталось НИ ОДНОГО — если единственные оставшиеся +# enabled-узлы это domclick (выделенная affinity, fallback НЕ имеет права её +# забрать при отсутствии backup — #2609). Проверяем ТОЧНО это расхождение. + + +def test_mark_banned_dedicated_affinity_alone_does_not_count_as_backup_for_other_source() -> None: + """avito банится; в пуле остаётся только один domclick-узел (affinity выделенная, + БЕЗ backup) — для avito это НЕ доступный узел (acquire('avito') не взял бы его через + fallback, #2609 protects last node of domclick). Защита должна сработать — бан + НЕ записывается, несмотря на то что COUNT(*) WHERE enabled было бы 2.""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="domclick")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db.bans == [] # domclick-узел НЕ считается доступной заменой + assert db._by_id(2)["enabled"] is True # и сам не тронут + + +def test_mark_banned_dedicated_affinity_with_backup_counts_as_fallback() -> None: + """Та же ситуация, но у domclick есть ВТОРОЙ узел (backup) — тогда fallback может + забрать ОДИН из них под avito (acquire()'s EXISTS-правило #2609), доступность для + avito сохраняется через fallback → бан avito-узла записывается безопасно.""" + assert mark_banned is not None + db = FakeSession( + [ + _proxy(1, affinity="avito"), + _proxy(2, affinity="domclick"), + _proxy(3, affinity="domclick"), + ] + ) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db._ban(1, "avito") is not None + + +def test_mark_banned_backup_of_dedicated_affinity_must_be_usable() -> None: + """Зеркало acquire-правила в защите (deep-review #2600 п.2). + + Узел 3 (domclick) забанен САМИМ domclick'ом и вдобавок в карантине по fails, т.е. + сам заменой для avito быть не может. Узел 2 — последний РАБОЧИЙ узел domclick, + fallback не имеет права его забрать. Значит замены для avito нет вообще → бан + avito-узла НЕ пишется. Со старым правилом («backup = любой enabled той же affinity») + узел 3 засчитался бы бэкапом, узел 2 стал бы «доступным» и бан бы записался. + """ + assert mark_banned is not None + db = FakeSession( + [ + _proxy(1, affinity="avito"), + _proxy(2, affinity="domclick"), + _proxy(3, affinity="domclick", fails=MAX_CONSECUTIVE_FAILS), + ], + bans=[ + { + "proxy_id": 3, + "source": "domclick", + "ban_count": 1, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS), + "reason": "banned:domclick", + } + ], + ) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db._ban(1, "avito") is None + + +def test_mark_banned_unhealthy_candidate_not_counted_as_backup() -> None: + """Кандидат формально enabled, но consecutive_fails>=MAX_CONSECUTIVE_FAILS (карантин, + acquire() его не выдаёт) — НЕ считается доступной заменой, защита срабатывает.""" + assert mark_banned is not None + db = FakeSession( + [_proxy(1, affinity="avito"), _proxy(2, affinity="any", fails=MAX_CONSECUTIVE_FAILS)] + ) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db.bans == [] # карантинный узел не спасает + + +# ── mark_banned: TOCTOU-защита (deep-review fix 2, #2600) ─────────────────────── +# +# Реальную гонку (два ПАРАЛЛЕЛЬНЫХ mark_banned на РАЗНЫХ proxy_id) честно юнитом не +# проверить — FakeSession однопоточна, а pg_advisory_xact_lock — свойство реальной +# СУБД (сериализация конкурентных транзакций), не что-то, что можно воспроизвести +# in-memory. Проверяем то, что юнитом ПРОВЕРИТЬ можно: лок реально берётся, с +# фиксированным ключом, ДО check+update (по SQL-подстроке — тот же паттерн, что и +# остальные тесты этого файла различают SQL веток). + + +def test_mark_banned_takes_advisory_xact_lock_with_fixed_key() -> None: + assert mark_banned is not None + from app.services.proxy_pool import _MARK_BANNED_ADVISORY_LOCK_KEY + + db = FakeSession([_proxy(1, affinity="avito"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db.advisory_lock_calls == [_MARK_BANNED_ADVISORY_LOCK_KEY] + + +def test_mark_banned_takes_advisory_lock_even_when_protected() -> None: + """Лок берётся ПЕРЕД проверкой доступности — даже когда защита последнего узла + в итоге отменяет запись бана, лок всё равно взят (сериализация check+decide, не + только сам INSERT).""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="avito")]) # единственный узел — protected + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + assert db.advisory_lock_calls # лок взят, хотя бан не записан + assert db.bans == [] + + +# ── acquire × бан по источнику (#2600 п.2 — суть задачи) ─────────────────────── +# +# Прод-факт, из-за которого всё затевалось: Авито банит IP, Яндекс через тот же IP +# ходит чисто. Глобальный enabled=false выкидывал живой узел из пула для ВСЕХ +# источников; per-source бан обязан выключать выдачу ровно одному. + + +def test_acquire_skips_node_banned_for_this_source() -> None: + """Единственный узел забанен ЭТИМ источником → acquire(source) возвращает None.""" + db = FakeSession( + [_proxy(1, affinity="any")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "ban_count": 1, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS), + "reason": "banned:avito", + } + ], + ) + assert acquire(db, "avito", run_id=1) is None # type: ignore[arg-type] + + +def test_acquire_still_issues_node_banned_by_other_source() -> None: + """ГЛАВНЫЙ тест задачи: узел забанен Авито — Яндексу он выдаётся как ни в чём не + бывало (бан — свойство пары, а не узла).""" + db = FakeSession( + [_proxy(1, affinity="any")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "ban_count": 1, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS), + "reason": "banned:avito", + } + ], + ) + lease = acquire(db, "yandex", run_id=2) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 1 + + +def test_acquire_ignores_expired_ban() -> None: + """Истёкшая бан-строка (banned_until в прошлом) выдаче не мешает — она ещё лежит + только ради ban_count (purge снесёт её позже, SOURCE_BAN_PURGE_DAYS).""" + db = FakeSession( + [_proxy(1, affinity="avito")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "ban_count": 2, + "banned_until": datetime.now(UTC) - timedelta(hours=1), + "reason": "banned:avito", + } + ], + ) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + +def test_acquire_fallback_also_respects_source_ban() -> None: + """Fallback-заход (чужая affinity) тоже отсекает забаненные для source узлы: два + cian-узла, один забанен avito → avito достаётся ВТОРОЙ, а не забаненный.""" + db = FakeSession( + [_proxy(1, affinity="cian"), _proxy(2, affinity="cian")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "ban_count": 1, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS), + "reason": "banned:avito", + } + ], + ) + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None + assert lease.id == 2 + + +def test_ban_then_acquire_end_to_end() -> None: + """Сквозной сценарий: mark_banned('avito') на узле 1 → avito получает узел 2, + yandex по-прежнему может получить узел 1.""" + assert mark_banned is not None + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="any")]) + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + + avito_lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert avito_lease is not None and avito_lease.id == 2 + + release(db, 2) # type: ignore[arg-type] + yandex_lease = acquire(db, "yandex", run_id=2) # type: ignore[arg-type] + assert yandex_lease is not None and yandex_lease.id == 1 # забанен только для avito + + +# ── purge истёкших бан-строк в run_proxy_healthcheck (#2600 п.2) ─────────────── + + +async def test_healthcheck_purges_long_expired_bans_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Сносим строки, истёкшие дольше SOURCE_BAN_PURGE_DAYS назад; истёкшую вчера + оставляем — в ней живёт ban_count (память об эскалации, см. комментарий у DELETE).""" + now = datetime.now(UTC) + db = FakeSession( + [_proxy(1, affinity="any")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "ban_count": 3, + "banned_until": now - timedelta(days=SOURCE_BAN_PURGE_DAYS + 1), + "reason": "banned:avito", + }, + { + "proxy_id": 1, + "source": "cian", + "ban_count": 2, + "banned_until": now - timedelta(days=1), + "reason": "banned:cian", + }, + ], + ) + + async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["bans_purged"] == 1 + assert [b["source"] for b in db.bans] == ["cian"] + + +# ── clear_source_bans (#2600 п.2 — рычаг оператора против ложного бана) ──────── +# +# До п.2 ложный бан лечился PATCH enabled=true (обнулял disabled_reason). Теперь бан +# в отдельной таблице и истекает только по таймеру (до 72ч при эскалации) — без этой +# ручки ложное срабатывание детектора капчи (#2642) снималось бы только руками в SQL. + + +def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]: + return { + "proxy_id": pid, + "source": source, + "ban_count": ban_count, + "banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_MAX_HOURS), + "reason": f"banned:{source}", + } + + +def test_clear_source_bans_removes_all_bans_of_node() -> None: + db = FakeSession( + [_proxy(1, affinity="any"), _proxy(2, affinity="any")], + bans=[_active_ban(1, "avito"), _active_ban(1, "cian"), _active_ban(2, "avito")], + ) + cleared = proxy_pool.clear_source_bans(db, 1, reason="manual enable") # type: ignore[arg-type] + assert cleared == 2 + assert [(b["proxy_id"], b["source"]) for b in db.bans] == [(2, "avito")] # чужой цел + # узел снова выдаётся источнику, который его банил + lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + +def test_clear_source_bans_single_source_keeps_others() -> None: + db = FakeSession( + [_proxy(1, affinity="any")], + bans=[_active_ban(1, "avito"), _active_ban(1, "cian")], + ) + cleared = proxy_pool.clear_source_bans( # type: ignore[arg-type] + db, 1, source="avito", reason="ip rotated" + ) + assert cleared == 1 + assert [b["source"] for b in db.bans] == ["cian"] + + +def test_clear_source_bans_noop_when_nothing_to_clear() -> None: + db = FakeSession([_proxy(1, affinity="any")]) + assert proxy_pool.clear_source_bans(db, 1, reason="manual enable") == 0 # type: ignore[arg-type] + + +def test_clear_source_bans_resets_escalation() -> None: + """DELETE, а не banned_until=now(): снятие обнуляет и ban_count — следующий бан + начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию.""" + assert mark_banned is not None + db = FakeSession( + [_proxy(1, affinity="any"), _proxy(2, affinity="any")], + bans=[_active_ban(1, "avito", ban_count=4)], + ) + proxy_pool.clear_source_bans(db, 1, reason="manual enable") # type: ignore[arg-type] + + mark_banned(db, 1, source="avito") # type: ignore[arg-type] + + ban = db._ban(1, "avito") + assert ban["ban_count"] == 1 + expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS) + assert abs((ban["banned_until"] - expected).total_seconds()) < 60 diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py new file mode 100644 index 00000000..a311b7c9 --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -0,0 +1,559 @@ +"""Offline-тесты ротации exit-IP ASocks (#2600 п.5). + +Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком, +FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations +(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml). + + - rotate_url пуст → «не поддерживается», НЕ ошибка, HTTP не дёргается. + - ASOCKS_API_TOKEN не задан → внятный отказ, HTTP не дёргается. + - 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки). + - Успешная ротация пишет запись в scrape_proxy_rotations (success=True). + - 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг), + аудит-запись пишется, но НЕ считается против суточного лимита. + - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в + тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry — + ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success). + - rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) → отказ ДО HTTP-вызова — + scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip- + ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import logging +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest + +from app.services import proxy_rotation + +SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c" + +# ── stateful fakes ──────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rows: list[dict[str, Any]]): + self._rows = rows + + def mappings(self) -> _FakeResult: + return self + + def fetchone(self) -> dict[str, Any] | None: + return self._rows[0] if self._rows else None + + def fetchall(self) -> list[Any]: + # RETURNING source у clear_source_bans — код читает r.source (attribute access) + return [type("Row", (), r)() for r in self._rows] + + +class FakeSession: + """Эмуляция Session: одна строка scrape_proxies + append-only + scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же + паттерн, что tests/services/test_proxy_pool.py).""" + + def __init__( + self, + proxy_row: dict[str, Any] | None, + rotations: list[dict[str, Any]] | None = None, + source_bans: list[dict[str, Any]] | None = None, + ): + self.proxy_row = proxy_row + self.rotations: list[dict[str, Any]] = rotations or [] + # #2600 п.2: успешная ротация снимает баны узла (IP сменился — бан старого + # адреса недействителен), см. proxy_pool.clear_source_bans. + self.source_bans: list[dict[str, Any]] = source_bans or [] + self.commits = 0 + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt) + p = params or {} + + if "SELECT id, rotate_url FROM scrape_proxies" in sql: + if self.proxy_row is None or self.proxy_row["id"] != p["id"]: + return _FakeResult([]) + return _FakeResult([dict(self.proxy_row)]) + + if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql: + cutoff = datetime.now(UTC) - timedelta(hours=24) + n = sum( + 1 + for r in self.rotations + if r["proxy_id"] == p["proxy_id"] + and r["rotated_at"] > cutoff + and r["http_status"] is not None + and r["http_status"] != 401 + ) + return _FakeResult([{"n": n}]) + + if "INSERT INTO scrape_proxy_rotations" in sql: + self.rotations.append( + { + "proxy_id": p["proxy_id"], + "success": p["success"], + "http_status": p["http_status"], + "note": p["note"], + "rotated_at": datetime.now(UTC), + } + ) + return _FakeResult([]) + + if "DELETE FROM scrape_proxy_source_bans" in sql: # clear_source_bans (#2600 п.2) + cleared = [b for b in self.source_bans if b["proxy_id"] == p["proxy_id"]] + self.source_bans = [b for b in self.source_bans if b not in cleared] + return _FakeResult([{"source": b["source"]} for b in cleared]) + + raise AssertionError(f"unhandled SQL: {sql}") + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + pass + + +class _FakeResponse: + def __init__(self, status_code: int, json_data: dict[str, Any] | None): + self.status_code = status_code + self._json_data = json_data + + def json(self) -> dict[str, Any]: + if self._json_data is None: + raise ValueError("no json body") + return self._json_data + + +def _fake_async_client( + *, + response: tuple[int, dict[str, Any] | None] | None, + exception: Exception | None, +): + """Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть. + + Ровно один из (response, exception) задан. calls накапливает (url, headers) + каждого post() — тест проверяет по ним, был ли вообще HTTP-вызов. + """ + calls: list[dict[str, Any]] = [] + + class _FakeClientImpl: + def __init__(self, timeout: float | None = None) -> None: + self.timeout = timeout + + async def __aenter__(self) -> _FakeClientImpl: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse: + calls.append({"url": url, "headers": headers or {}}) + if exception is not None: + raise exception + assert response is not None + status, body = response + return _FakeResponse(status, body) + + return _FakeClientImpl, calls + + +def _no_http_allowed(): + """httpx.AsyncClient-заглушка, падающая AssertionError при любом post() — + для сценариев, где HTTP до провайдера дойти НЕ должно.""" + + class _ForbiddenClient: + def __init__(self, timeout: float | None = None) -> None: + pass + + async def __aenter__(self) -> _ForbiddenClient: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def post(self, *a: object, **kw: object) -> None: + raise AssertionError("HTTP call must NOT happen for this scenario") + + return _ForbiddenClient + + +_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" + +# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один +# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт). +_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None] + + +def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]: + return {"id": 1, "rotate_url": rotate_url} + + +def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]: + now = datetime.now(UTC) + return [ + { + "proxy_id": proxy_id, + "success": http_status < 400, + "http_status": http_status, + "note": None, + "rotated_at": now - timedelta(minutes=i), + } + for i in range(n) + ] + + +# ── no rotate_url → not an error ──────────────────────────────────────────── + + +async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url=None)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + assert "rotat" in result.reason.lower() # human-readable, not a crash + assert db.rotations == [] # ничего не писалось — попытки не было + + +# ── host pinning (security review PR #2611) ───────────────────────────────── +# +# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy +# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция +# 199). Без host-пиннинга наш Authorization: Bearer ушёл бы +# на чужой провайдер. + + +async def test_rotate_url_on_foreign_host_refused_before_http_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret" + db = FakeSession(_proxy_row(rotate_url=foreign_url)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + # _no_http_allowed() would have raised AssertionError from within rotate_proxy + # if the code had tried an HTTP call (i.e. sent our token) — reaching this + # line means it refused first. Belt-and-suspenders: no audit row either + # (this is a local rejection, same as no-rotate_url/no-token/limit). + assert db.rotations == [] + assert SECRET_TOKEN not in result.reason + + +async def test_allowed_host_case_insensitive_still_proceeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот + же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг + превратился бы в ложный отказ на легитимном rotate_url.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip")) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert len(calls) == 1 + + +async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """http:// (не https://) на тот же хост — отказ (защита от даунгрейда + транспорта, которым Authorization ушёл бы в открытом виде).""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip")) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.rotations == [] + + +# ── missing token → neutral refusal, no crash ─────────────────────────────── + + +async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "") + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + assert db.rotations == [] + + +# ── daily limit ────────────────────────────────────────────────────────────── + + +async def test_fourth_attempt_today_rejected_without_api_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + # 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются). + db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.DAILY_ROTATION_LIMIT)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower() + assert result.rotations_remaining_today == 0 + # _no_http_allowed() would have raised AssertionError from within rotate_proxy + # if the code had tried an HTTP call — reaching here means it didn't. + assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано + + +# ── success writes history ────────────────────────────────────────────────── + + +async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert result.new_ip == "9.9.9.9" + assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1 + assert len(calls) == 1 + assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}" + + assert len(db.rotations) == 1 + row = db.rotations[0] + assert row["success"] is True + assert row["http_status"] == 200 + assert db.commits >= 1 + + +async def test_successful_rotation_clears_source_bans(monkeypatch: pytest.MonkeyPatch) -> None: + """(#2600 п.2) Сменился exit-IP → баны площадок на СТАРОМ адресе недействительны. + + Строка бана привязана к proxy_id, а не к IP — без снятия узел остался бы вне выдачи + источнику до 72 часов уже без причины. + """ + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession( + _proxy_row(), + source_bans=[ + {"proxy_id": 1, "source": "avito"}, + {"proxy_id": 1, "source": "cian"}, + {"proxy_id": 2, "source": "avito"}, # чужой узел — не трогаем + ], + ) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert db.source_bans == [{"proxy_id": 2, "source": "avito"}] + + +async def test_failed_rotation_keeps_source_bans(monkeypatch: pytest.MonkeyPatch) -> None: + """Провайдер ответил ошибкой — IP НЕ сменился, баны обязаны остаться.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _calls = _fake_async_client(response=(500, None), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(), source_bans=[{"proxy_id": 1, "source": "avito"}]) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.source_bans == [{"proxy_id": 1, "source": "avito"}] + + +# ── 401 → loud failure ─────────────────────────────────────────────────────── + + +async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client( + response=(401, {"success": False, "message": "Unauthenticated"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + sentry_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + "sentry_sdk.capture_message", + lambda msg, level=None: sentry_calls.append((msg, level)), + ) + + db = FakeSession(_proxy_row()) + with caplog.at_level(logging.ERROR): + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert len(calls) == 1 # запрос реально ушёл + + # громкий отказ: и лог, и мониторинг — не молчаливая остановка + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert any("401" in r.getMessage() for r in error_records) + assert len(sentry_calls) == 1 + assert sentry_calls[0][1] == "error" + + # аудит записан, но 401 НЕ считается против суточного лимита (см. модуль + # docstring: auth-отсев до провайдера, лимит на его стороне не тратится). + assert len(db.rotations) == 1 + assert db.rotations[0]["http_status"] == 401 + assert db.rotations[0]["success"] is False + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + + second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + # 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен + assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT + + +# ── token never leaks ──────────────────────────────────────────────────────── + + +async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + assert SECRET_TOKEN not in (result.reason or "") + assert SECRET_TOKEN not in (result.new_ip or "") + + +async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None) + fake_client, _ = _fake_async_client( + response=(401, {"message": "Unauthenticated"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + assert SECRET_TOKEN not in (result.reason or "") + assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations) + + +async def test_token_never_appears_in_reason_on_network_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """httpx-исключения могут нести полный request-контекст (URL/детали) — + прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен + живёт только в headers (не в URL), но проверяем end-to-end: даже если + exception-текст содержит секрет (симулируем это явно), наружу он не идёт.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}") + fake_client, _ = _fake_async_client(response=None, exception=boom) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert SECRET_TOKEN not in (result.reason or "") + assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations) + # сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится + assert db.rotations[0]["http_status"] is None + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + # exception class name (не секрет) в note — оператор отличит "не дозвонились" + # (ConnectError) от "дозвонились, зависли" (ReadTimeout). + assert "ConnectError" in (db.rotations[0]["note"] or "") + + +async def test_token_never_appears_on_provider_error_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _ = _fake_async_client( + response=(500, {"message": "internal error"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert SECRET_TOKEN not in (result.reason or "") + # провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту + assert db.rotations[0]["http_status"] == 500 + assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type] + + +async def test_token_never_appears_in_log_messages_or_sentry_text( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Расширенное leak-покрытие (security review PR #2611): предыдущие тесты + проверяли только reason/note. Здесь — текст, реально уходящий в logging и в + Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали + исключения — см. модуль docstring; это осознанно разрешённое место). + caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ + exc_text — то есть эта проверка ловит именно "секрет попал в аргумент + logger.*()", а не в traceback. + """ + sentry_texts: list[str] = [] + monkeypatch.setattr( + "sentry_sdk.capture_message", + lambda msg, level=None: sentry_texts.append(msg), + ) + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + + scenarios: list[_LogScenario] = [ + ("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None), + ("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None), + ("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None), + ( + "network_error", + _DEFAULT_ROTATE_URL, + None, + httpx.ConnectError(f"boom token={SECRET_TOKEN}"), + ), + ("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None), + ] + + for name, rotate_url, response, exception in scenarios: + if response is not None or exception is not None: + fake_client, _ = _fake_async_client(response=response, exception=exception) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + else: + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url=rotate_url)) + with caplog.at_level(logging.DEBUG): + caplog.clear() + await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + for record in caplog.records: + assert ( + SECRET_TOKEN not in record.getMessage() + ), f"scenario={name}: token leaked into log message args" + + assert sentry_texts, "expected at least one Sentry capture (401 scenario)" + assert all(SECRET_TOKEN not in text for text in sentry_texts) + + +# ── proxy not found ────────────────────────────────────────────────────────── + + +async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(None) + result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type] + assert result.ok is False + assert result.reason is not None diff --git a/tradein-mvp/backend/tests/support/identity_modes.py b/tradein-mvp/backend/tests/support/identity_modes.py new file mode 100644 index 00000000..73c5672a --- /dev/null +++ b/tradein-mvp/backend/tests/support/identity_modes.py @@ -0,0 +1,200 @@ +"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди. + +Эпик «единый вход»: `settings.identity_store` переключает код «Меры» между +`tradein_users`/`tradein_sessions` (БД tradein — ДЕФОЛТ, сегодняшнее поведение +прода) и `users`/`sessions` (БД `auth`). Различаются имена таблиц И тип колонки +состояния доступа (`is_active boolean` против `access_state text`). + +⚠️ ЗАЧЕМ ЭТОТ МОДУЛЬ (главная ловушка этих тестов). Интеграционные тесты +`test_auth_api.py` / `test_team_api.py` используют fake-DB, который диспатчит по +ТЕКСТУ SQL. Если ветка такого fake'а сравнивает с литералом «tradein_users», то +при `identity_store="auth"` она просто перестаёт матчиться — fake вернёт пустой +результат вместо строки, а тест останется ЗЕЛЁНЫМ на сломанном коде. Поэтому: + + * имена для матчинга берутся из `identity_schema()` (`sql_names()` ниже) — + ровно оттуда же, откуда их берёт продакшн-код; + * непонятый SQL в fake'ах ОБЯЗАН падать `AssertionError`, а не возвращать + пустоту (см. `raise AssertionError(f"unhandled fake SQL ...")` в обоих + файлах) — это то, что превращает «ветка отвалилась» в красный тест. + +`column_value()` — намеренно ЛИТЕРАЛЬНАЯ таблица «состояние → значение +колонки», а НЕ вызов `identity_store.access_state_param()`. Fake обязан хранить +то, что реально лежало бы в Postgres; если бы он звал ту же production-функцию, +что и проверяемый код, её инверсия (`active` ↔ `disabled`) прошла бы round-trip +через fake незамеченной, и тест бы не покраснел. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any + +import pytest + +from app.core import auth_db, config +from app.services import identity_store +from app.services.identity_store import AccessState, identity_schema + +# Оба допустимых значения `IDENTITY_STORE` (Literal в pydantic-настройках). +# "tradein" ПЕРВЫЙ — это дефолт и путь прода; при чтении вывода pytest'а первый +# параметр всегда «как сейчас», второй — «после переезда». +IDENTITY_MODES = ("tradein", "auth") + +# Состояние доступа → значение, которое реально лежит в колонке реестра. +# Литералы, независимые от production-кода (см. модульный docstring). +# `trial_expired` в булевой схеме ОТСУТСТВУЕТ: состояния «пробный период истёк» +# там не существовало, выразить его нечем — тесты про него имеют смысл только в +# режиме `auth`, поэтому здесь явная ошибка вместо тихого приведения к False. +_COLUMN_VALUE: dict[tuple[str, AccessState], bool | str] = { + ("tradein", AccessState.ACTIVE): True, + ("tradein", AccessState.DISABLED): False, + ("auth", AccessState.ACTIVE): "active", + ("auth", AccessState.TRIAL_EXPIRED): "trial_expired", + ("auth", AccessState.DISABLED): "disabled", +} + + +def column_value(state: AccessState) -> bool | str: + """Значение состояния *state* в колонке реестра для ТЕКУЩЕГО режима.""" + store = config.settings.identity_store + try: + return _COLUMN_VALUE[(store, state)] + except KeyError: + raise AssertionError( + f"состояние {state.value!r} не существует в схеме {store!r} — " + f"такой тест имеет смысл только при identity_store='auth'" + ) from None + + +@dataclass(frozen=True, slots=True) +class SqlNames: + """Имена, по которым fake-DB узнаёт запрос в ТЕКУЩЕМ режиме.""" + + users: str + sessions: str + access_state_column: str + access_state_sql_type: str + + +def sql_names() -> SqlNames: + """Имена таблиц/колонки из `identity_schema()` — источник тот же, что у кода.""" + schema = identity_schema() + return SqlNames( + users=schema.users_table, + sessions=schema.sessions_table, + access_state_column=schema.access_state_column, + access_state_sql_type=schema.access_state_sql_type, + ) + + +def assert_reads_access_state(sql: str, names: SqlNames) -> None: + """Запрос, читающий состояние доступа, ОБЯЗАН брать колонку ТЕКУЩЕГО режима. + + Ставится в те ветки fake-DB, которые отдают строку человека. Без неё fake + остаётся ЗЕЛЁНЫМ на захардкоженном `is_active AS access_state`: строку он + собирает из `_Store`, где ключ УЖЕ называется `access_state`, и про имя + колонки в SELECT'е ничего не знает — то есть запрос, невозможный на реальном + Postgres (`column "is_active" does not exist` в БД `auth`), проехал бы молча. + + Измерено мутацией: захардкодить колонку в `team._employee_columns` — без + этой проверки все 48 тестов «Команды» остаются зелёными; с ней ветка + перестаёт матчиться, SQL доезжает до `raise AssertionError` в конце + `execute` и тесты краснеют. + + Алиас проверяется отдельно от имени колонки: без `AS access_state` + вызывающий код читал бы то `is_active`, то `access_state`, то есть завёл бы + второе представление состояния — ровно то, чего эпик не допускает. + """ + expected = f"{names.access_state_column} AS access_state" + if expected not in sql: + raise AssertionError( + f"запрос к реестру не читает колонку состояния текущего режима " + f"({expected!r}): {sql!r}" + ) + + +def assert_insert_writes_access_state(sql: str, names: SqlNames) -> None: + """INSERT в реестр обязан перечислять колонку состояния ТЕКУЩЕГО режима. + + Проверяется именно СПИСОК КОЛОНОК, а не наличие подстроки: bind-параметр + называется `:access_state` в обоих режимах, поэтому `... , :access_state)` + в VALUES матчился бы всегда — и `INSERT INTO users (..., is_active)` + (невозможный в БД `auth`) проехал бы молча. Измерено мутацией. + """ + match = re.search(rf"INSERT INTO\s+{re.escape(names.users)}\s*\(([^)]*)\)", sql) + if match is None: + raise AssertionError(f"не разобрал список колонок INSERT'а в реестр: {sql!r}") + columns = {c.strip() for c in match.group(1).split(",")} + if names.access_state_column not in columns: + raise AssertionError( + f"INSERT в реестр не пишет колонку состояния текущего режима " + f"({names.access_state_column!r}); в списке: {sorted(columns)}" + ) + + +def assert_update_writes_access_state(sql: str, names: SqlNames) -> None: + """UPDATE реестра обязан присваивать колонку состояния ТЕКУЩЕГО режима — и + кастовать параметр в ЕЁ тип. + + CAST здесь несущий: параметр может быть NULL («поле не пришло в PATCH» → + `COALESCE(CAST(:x AS T), col)`), и без явного типа Postgres тип NULL-параметра + не выведет. Захардкоженный `boolean` в текстовой схеме — ошибка уровня БД, + которую fake иначе не увидел бы. + """ + assignment = f"{names.access_state_column} = COALESCE(" + if assignment not in sql: + raise AssertionError( + f"UPDATE реестра не присваивает колонку состояния текущего режима " + f"({assignment!r}): {sql!r}" + ) + cast = f"CAST(:access_state AS {names.access_state_sql_type})" + if cast not in sql: + raise AssertionError( + f"UPDATE реестра кастует состояние не в тип текущей схемы ({cast!r}): {sql!r}" + ) + + +def use_identity_mode(monkeypatch: pytest.MonkeyPatch, mode: str) -> str: + """Переключает реестр на *mode* на время теста. + + `reset_auth_db()` — на случай, если предыдущий тест успел построить engine + БД `auth`: закешированный engine пережил бы monkeypatch настроек (он живёт в + module-global, а не в `settings`) и утёк бы сюда. + """ + auth_db.reset_auth_db() + monkeypatch.setattr(config.settings, "identity_store", mode) + return mode + + +def patch_identity_sessions(monkeypatch: pytest.MonkeyPatch, make_db: Callable[[], Any]) -> None: + """Подменяет ОБА источника сессии реестра так, чтобы работал РЕАЛЬНЫЙ + `identity_store.identity_session()` / `get_identity_db()`, а не их копия + в тесте. + + Точки подмены выбраны настолько «низко», насколько возможно: + * `identity_store.SessionLocal` — то, что открывает `identity_session()` + в режиме "tradein" (импортирован по имени, поэтому патчим в + `identity_store`, а не в `app.core.db`); + * `auth_db.auth_session` — то, что открывают `identity_session()` и + `get_identity_db()` в режиме "auth" (`identity_store` держит ссылку на + МОДУЛЬ `auth_db`, поэтому подмена атрибута модуля видна ему сразу). + + Благодаря этому ветвление по режиму остаётся на production-коде: тест не + повторяет его у себя, и регрессия в `get_identity_db` (например, если он + перестанет отдавать в режиме "tradein" тот же объект `Session`, что и + `get_db`) не сможет спрятаться за тестовым дублёром. + + *make_db* вызывается БЕЗ аргументов и обязан отдавать новый fake-Session, + поддерживающий `with ... as db` (как настоящая `Session`). + """ + + @contextmanager + def _fake_auth_session() -> Iterator[Any]: + with make_db() as db: + yield db + + monkeypatch.setattr(identity_store, "SessionLocal", make_db) + monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session) diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index eae34c4a..a30a3760 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import fnmatch import os import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -14,6 +15,7 @@ import pytest # noqa: E402 from app.core import shutdown as _sd # noqa: E402 from app.tasks.avito_detail_backfill import ( # noqa: E402 + _OBLAST_AVITO_URL_PATTERNS, AvitoDetailBackfillResult, run_avito_detail_backfill, ) @@ -136,11 +138,11 @@ async def test_backfill_processes_snapshot_to_completion() -> None: assert result.blocked == 0 assert result.failed == 0 assert mock_fetch.call_count == 3 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() # #2310 regression guard: kit fetch_detail silently drops the backconnect- - # on-403 retry (and kit AvitoScraper can't read avito_proxy_rotate_url at - # all) unless config=RealScraperConfig() is passed/injected at the call + # on-403 retry (and kit AvitoScraper can't read scraper_proxy_url at all) + # unless config=RealScraperConfig() is passed/injected at the call # site — assert_called()/call_count alone wouldn't catch someone dropping # that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern). _, fetch_call_kwargs = mock_fetch.call_args @@ -186,13 +188,16 @@ async def test_backfill_build_warmed_session_receives_config() -> None: mock_build.assert_awaited_once() _, build_call_kwargs = mock_build.call_args assert isinstance(build_call_kwargs.get("config"), RealScraperConfig) - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_blocked_abort_after_max_consecutive() -> None: - """5 consecutive AvitoBlockedError -> abort, mark_done (NOT mark_failed). + """5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674). + + Раньше — mark_done; на проде 13 прогонов attempted=5 blocked=5 enriched=0 + назывались успехом. Теперь флаг обрыва → статус 'banned'. #1950 abort-reorder: abort-check ПЕРЕД recovery → на 5-м (аборт-)блоке rotate_ip НЕ дёргается (не тратим recovery на финальном блоке). rotate_ip x4 (блоки 1-4). @@ -224,7 +229,8 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None: assert result.blocked == 5 assert result.attempted == 5 assert result.enriched == 0 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() + assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True runs.mark_failed.assert_not_called() # abort-check до recovery → 5-й блок абортит без rotate; rotate только на блоках 1-4. assert mock_scraper.return_value._rotate_ip.call_count == 4 @@ -264,11 +270,11 @@ async def test_backfill_sigterm_drain_breaks_and_marks_done_partial() -> None: assert result.attempted == 1 assert result.enriched == 1 assert mock_fetch.call_count == 1 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() runs.mark_cancelled.assert_not_called() # mark_done получил ЧАСТИЧНЫЕ счётчики (attempted=1, а не весь snapshot=3). - done_counters = runs.mark_done.call_args.args[2] + done_counters = runs.mark_backfill_finished.call_args.args[2] assert done_counters["attempted"] == 1 @@ -291,7 +297,7 @@ async def test_backfill_budget_guard_stops_loop() -> None: await run_avito_detail_backfill(db, run_id=4, params={"batch_size": 5, "budget_sec": 1}) mock_fetch.assert_not_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -313,7 +319,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None: ) runs.mark_failed.assert_called_once() - runs.mark_done.assert_not_called() + runs.mark_backfill_finished.assert_not_called() @pytest.mark.asyncio @@ -348,16 +354,19 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None: assert result.enriched == 1 assert result.blocked == 1 assert mock_scraper.return_value._rotate_ip.call_count == 1 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio async def test_backfill_snapshot_filters_ekb_active_only() -> None: - """Снапшот-SELECT (#1814) фильтрует только активные ЕКБ-листинги. + """Снапшот-SELECT (#1814, расширено #2576) фильтрует активные ЕКБ- И + известные oblast-листинги (region 66), НЕ всё подряд. - Проверяем, что текст запроса содержит `is_active = TRUE` и - `LIKE '%/ekaterinburg/%'` — legacy не-ЕКБ (moskva/spb/tyumen) и мёртвые - листинги не попадают в фетч, иначе browser спотыкается → curl-бан 429. + Проверяем, что текст запроса содержит `is_active = TRUE`, `LIKE + '%/ekaterinburg/%'` (ekb CTE, LIMIT batch_size НЕ сокращён) и `LIKE ANY(...)` + по oblast-паттернам (oblast CTE, отдельный LIMIT oblast_batch_size) — legacy + не-ЕКБ/не-область (moskva/spb/tyumen) и мёртвые листинги не попадают в фетч, + иначе browser спотыкается → curl-бан 429. """ db = _mock_db([]) runs = MagicMock() @@ -376,8 +385,127 @@ async def test_backfill_snapshot_filters_ekb_active_only() -> None: sql_text = str(snapshot_call.args[0]) assert "is_active = TRUE" in sql_text assert "/ekaterinburg/" in sql_text + assert "LIKE ANY(CAST(:oblast_patterns AS text[]))" in sql_text assert "detail_enriched_at IS NULL" in sql_text assert "(lat IS NULL) DESC" in sql_text + assert "CAST(:batch_size AS int)" in sql_text + assert "CAST(:oblast_batch_size AS int)" in sql_text + + # ekb-квота передаётся batch_size БЕЗ урезания (#2576 требование "ЕКБ не + # деградирует") — oblast получает отдельный (не вычтенный) bind-параметр. + bind_params = snapshot_call.args[1] + assert bind_params["batch_size"] == 10 + assert bind_params["oblast_batch_size"] == 100 # default + assert set(bind_params["oblast_patterns"]) == set(_OBLAST_AVITO_URL_PATTERNS) + + +def test_oblast_avito_url_patterns_cover_region66_cities() -> None: + """#2576: _OBLAST_AVITO_URL_PATTERNS строится из CITY_LOCATIONS.avito_slug — + список должен покрывать реальные Avito-слаги oblast-городов (в т.ч. те, что + ОТЛИЧАЮТСЯ от нашего city_slug: kamensk-uralskiy через дефис, а не + kamensk_uralskiy). + + #2578 review: '_' в слаге -- LIKE wildcard, экранируем при построении паттерна + ('_' -> '\\_') -- nizhniy_tagil/verhnyaya_pyshma здесь ожидаются С обратным + слэшем перед '_', НЕ голым подчёркиванием.""" + assert "%/kamensk-uralskiy/%" in _OBLAST_AVITO_URL_PATTERNS + assert "%/nizhniy\\_tagil/%" in _OBLAST_AVITO_URL_PATTERNS + assert "%/pervouralsk/%" in _OBLAST_AVITO_URL_PATTERNS + assert "%/verhnyaya\\_pyshma/%" in _OBLAST_AVITO_URL_PATTERNS + assert "%/serov/%" in _OBLAST_AVITO_URL_PATTERNS + # ЕКБ обрабатывается отдельным жёстко закодированным паттерном (ekb CTE), + # НЕ через этот oblast-список — не должен в него затесаться. + assert not any("ekaterinburg" in p for p in _OBLAST_AVITO_URL_PATTERNS) + + +def _like_pattern_to_fnmatch(pattern: str) -> str: + """Точный перевод семантики Postgres `LIKE` (default `ESCAPE '\\'`) в fnmatch- + паттерн -- посимвольно, а НЕ наивным `.replace()`. + + LIKE: `%` = любая последовательность символов, `_` = РОВНО один любой символ, + `\\%`/`\\_`/`\\\\` = литералы (экранирование). fnmatch: `*` = любая + последовательность, `?` = один любой символ; голые `_`/`%` в fnmatch не + специальны (можно вставлять как литерал без экранирования). + + #2578 review: наивный `pat.replace("%", "*")` (как было раньше) НЕ отражал бы + семантику `_` вообще -- fnmatch трактует `_` как литерал, LIKE -- как wildcard. + Из-за этого расхождения прежний тест не поймал бы латентный баг (нет + экранирования `_` в продовых паттернах). Посимвольный разбор здесь корректно + различает голый `_` (-> `?` wildcard) и экранированный `\\_` (-> литерал `_`). + """ + out: list[str] = [] + i = 0 + n = len(pattern) + while i < n: + ch = pattern[i] + if ch == "\\" and i + 1 < n and pattern[i + 1] in ("%", "_", "\\"): + out.append(pattern[i + 1]) # экранированный символ -> литерал as-is + i += 2 + continue + if ch == "%": + out.append("*") + elif ch == "_": + out.append("?") + else: + out.append(ch) + i += 1 + return "".join(out) + + +def _in_oblast_or_ekb_scope(source_url: str) -> bool: + """Локальная реплика WHERE-условия snapshot-запроса (ekb CTE OR oblast CTE) + через корректную LIKE-эмуляцию -- без поднятия БД.""" + if fnmatch.fnmatchcase(source_url, _like_pattern_to_fnmatch("%/ekaterinburg/%")): + return True + return any( + fnmatch.fnmatchcase(source_url, _like_pattern_to_fnmatch(pat)) + for pat in _OBLAST_AVITO_URL_PATTERNS + ) + + +def test_oblast_avito_url_patterns_include_oblast_and_ekb_exclude_foreign_region() -> None: + """#2576 DoD: листинг города области и екатеринбургский листинг проходят + scope-фильтр; листинг чужого региона (Москва/СПб) — нет. + + Использует корректную LIKE-эмуляцию (_like_pattern_to_fnmatch), а не наивный + `%` -> `*` replace (#2578 review — тот не различал бы `_`-семантику).""" + # Область (Каменск-Уральский, #2576 — реальный кейс из тикета) -- проходит. + assert _in_oblast_or_ekb_scope("https://www.avito.ru/kamensk-uralskiy/kvartiry/prodam_123") + # ЕКБ — по-прежнему проходит (не деградировал). + assert _in_oblast_or_ekb_scope("https://www.avito.ru/ekaterinburg/kvartiry/prodam_456") + # Чужой регион — НЕ проходит (иначе поехали бы Москва/СПб/Тюмень legacy-строки). + assert not _in_oblast_or_ekb_scope("https://www.avito.ru/moskva/kvartiry/prodam_789") + assert not _in_oblast_or_ekb_scope("https://www.avito.ru/sankt-peterburg/kvartiry/prodam_000") + + +def test_like_underscore_wildcard_regression_caught_by_escaped_patterns() -> None: + """#2578 deep-review latent bug: Postgres `LIKE` трактует `_` как wildcard + "ровно один любой символ", а НЕ литерал. Два слага из пяти (nizhniy_tagil, + verhnyaya_pyshma) содержат `_` -- БЕЗ экранирования 'nizhniy_tagil' молча + совпал бы с 'nizhniyXtagil' (X = любой символ), т.е. коллизия слагов при + появлении похожего города. Сегодня коллизий нет (проверено на проде: raw vs + escaped паттерны дают одинаковые 776 совпадений), но дыра латентная. + + Этот тест ДОЛЖЕН падать на RAW (неэкранированном) варианте паттерна -- именно + так выглядели продовые паттерны ДО фикса #2578 (`%/nizhniy_tagil/%`, без + `\\`). Экранированный прод-паттерн (_OBLAST_AVITO_URL_PATTERNS, ПОСЛЕ фикса) + коллизию отклоняет, точный слаг по-прежнему матчит (позитивный кейс жив). + """ + raw_pattern = "%/nizhniy_tagil/%" # как было бы БЕЗ фикса #2578 (голый '_') + escaped_pattern = next(p for p in _OBLAST_AVITO_URL_PATTERNS if "nizhniy" in p) + # Сам факт экранирования: прод-паттерн ДОЛЖЕН отличаться от raw ('_' -> '\_'). + assert escaped_pattern != raw_pattern, "фикс #2578 должен экранировать '_' в avito_slug" + + collision_url = "https://www.avito.ru/nizhniyXtagil/kvartiry/prodam_1" + exact_url = "https://www.avito.ru/nizhniy_tagil/kvartiry/prodam_1" + + # RAW: '_' -- wildcard -> ложное совпадение с ЛЮБЫМ символом на его месте. + assert fnmatch.fnmatchcase(collision_url, _like_pattern_to_fnmatch(raw_pattern)) + # Экранированный прод-паттерн (после фикса): '_' -- литерал -> коллизия отклонена. + assert not fnmatch.fnmatchcase(collision_url, _like_pattern_to_fnmatch(escaped_pattern)) + # Позитивный кейс не сломан: точный слаг матчит ОБА варианта паттерна. + assert fnmatch.fnmatchcase(exact_url, _like_pattern_to_fnmatch(raw_pattern)) + assert fnmatch.fnmatchcase(exact_url, _like_pattern_to_fnmatch(escaped_pattern)) @pytest.mark.asyncio @@ -406,7 +534,7 @@ async def test_backfill_fetch_exception_continues() -> None: assert result.enriched == 1 assert result.attempted == 2 db.rollback.assert_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -463,7 +591,7 @@ async def test_backfill_fetch_timeout_skips_and_continues() -> None: assert result.attempted == 2 assert len(call_urls) == 2, "loop должен дойти до второго листинга, а не зависнуть" db.rollback.assert_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @@ -507,7 +635,7 @@ async def test_backfill_listing_gone_marks_inactive_no_breaker() -> None: assert result.enriched == 0 # breaker НЕ абортил: rotate_ip НЕ дёргался (gone ≠ block), run завершён mark_done. mock_scraper.return_value._rotate_ip.assert_not_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() # UPDATE listings SET is_active = FALSE по row id=1 выполнен (мок db.execute). update_calls = [ @@ -559,7 +687,7 @@ async def test_backfill_use_curl_flag_skips_browser_fetcher() -> None: _, kwargs = mock_fetch.call_args assert kwargs.get("browser_fetcher") is None assert result.enriched == 1 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -604,7 +732,7 @@ async def test_backfill_use_curl_false_creates_browser_fetcher() -> None: _, kwargs = mock_fetch.call_args assert kwargs.get("browser_fetcher") is not None assert result.enriched == 1 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -660,5 +788,5 @@ async def test_backfill_use_curl_block_cooldown_research_no_rebuild() -> None: assert mock_build.await_count == 1 # changeip-ротация (legacy путь) под use_curl НЕ дёргается. mock_scraper.return_value._rotate_ip.assert_not_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() diff --git a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py index c2db0780..bcfafebc 100644 --- a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py @@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) -from app.services.geocoder import GeocodeSuggestion # noqa: E402 +from app.services.geocoder import GeocodeSuggestion, _names_non_ekb_city # noqa: E402 from app.tasks import backfill_listings_coords_geoportal as bcg # noqa: E402 from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402 BackfillCoordsResult, @@ -203,6 +203,207 @@ def test_unparseable_address_counted_as_no_address() -> None: mock_geo.assert_not_called() +# ── городской гейт (#2583, H3) ───────────────────────────────────────────────── + + +def test_non_ekb_address_skipped_not_matched_against_ekb_registry() -> None: + """Адрес другого города области (Нижний Тагил) НЕ должен матчиться против + EKB-only ekb_geoportal_buildings, даже если street+house буквально совпадают + с ЕКБ-домом («проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле, #2583 H3). + + _names_non_ekb_city — РЕАЛЬНАЯ функция (не мок), чтобы проверить настоящий гейт, + а не заглушку. _geoportal_house_match мокнут так, будто он ложно нашёл ЕКБ-дом — + тест доказывает, что до этого вызова дело не доходит вообще. + """ + rows = [{"id": 100, "address": "г. Нижний Тагил, проспект Ленина, 1"}] + db = _make_db([rows, []]) + + # Sanity: реальная функция действительно считает этот адрес не-ЕКБ. + assert _names_non_ekb_city("г. Нижний Тагил, проспект Ленина, 1") is True + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, # ложное совпадение по street+house, если бы гейт не сработал + ) as mock_geo, + patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse, + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 1 + assert res.matched == 0 + assert res.updated == 0 + assert res.no_match == 0 + assert res.no_address == 0 + # Ни парсер, ни geoportal-матчер не должны были вызываться — гейт стоит раньше. + mock_parse.assert_not_called() + mock_geo.assert_not_called() + update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])] + assert len(update_calls) == 0 + + +def test_ekb_address_still_matched_with_real_gate() -> None: + """Явно ЕКБ-адрес по-прежнему проходит гейт и матчится как раньше (не деградация). + + _names_non_ekb_city — реальная функция: "Екатеринбург" в адресе имеет приоритет + над любым омонимом другого города области (см. docstring _names_non_ekb_city). + """ + rows = [{"id": 101, "address": "г. Екатеринбург, проспект Ленина, 1"}] + db = _make_db([rows, []]) + + assert _names_non_ekb_city("г. Екатеринбург, проспект Ленина, 1") is False + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ) as mock_geo, + patch( + "app.tasks.backfill_listings_coords_geoportal._parse_street_house", + return_value=("проспект ленина", "1"), + ), + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 0 + assert res.matched == 1 + assert res.updated == 1 + mock_geo.assert_called_once_with(db, "проспект ленина", "1") + + +# ── городской гейт по колонке listings.city (#2594, миграция 196, шаг 2/3) ────── + + +def test_city_column_non_ekb_skips_before_text_gate_and_match() -> None: + """listings.city='Нижний Тагил', но address НЕ называет город в тексте + ("ул. Победы, 30" — bare form). Текстовый гейт (_names_non_ekb_city) пропустил + бы этот адрес дальше (город нигде явно не назван в тексте), но колонка city — + надёжный сигнал из контекста развёртки скрапера — должна перехватить его + раньше матча против EKB-only ekb_geoportal_buildings, иначе адрес получил бы + ложные екатеринбургские координаты (issue #2594).""" + rows = [{"id": 200, "address": "ул. Победы, 30", "city": "Нижний Тагил"}] + db = _make_db([rows, []]) + + # Sanity: текстовый гейт САМ ПО СЕБЕ не поймал бы этот bare-адрес. + assert _names_non_ekb_city("ул. Победы, 30") is False + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, # ложный ЕКБ-матч, если бы гейт по колонке не сработал + ) as mock_geo, + patch( + "app.tasks.backfill_listings_coords_geoportal._parse_street_house", + return_value=("победы", "30"), + ) as mock_parse, + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 1 + assert res.matched == 0 + assert res.updated == 0 + # Ни парсер, ни geoportal-матчер не должны были вызываться — гейт по колонке + # стоит раньше текстового гейта и раньше парсинга/матча. + mock_parse.assert_not_called() + mock_geo.assert_not_called() + update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])] + assert len(update_calls) == 0 + + +def test_city_column_ekb_still_matched_not_a_regression() -> None: + """listings.city='Екатеринбург' — гейт по колонке пропускает дальше, как раньше.""" + rows = [{"id": 201, "address": "ул. Победы, 30", "city": "Екатеринбург"}] + db = _make_db([rows, []]) + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ) as mock_geo, + patch( + "app.tasks.backfill_listings_coords_geoportal._parse_street_house", + return_value=("победы", "30"), + ), + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 0 + assert res.matched == 1 + assert res.updated == 1 + mock_geo.assert_called_once_with(db, "победы", "30") + + +def test_city_column_null_falls_back_to_text_gate() -> None: + """listings.city IS NULL (записан до миграции 196) — гейт по колонке молчит, + решение остаётся за текстовым гейтом _names_non_ekb_city (не деградация #2583).""" + rows = [{"id": 202, "address": "г. Нижний Тагил, проспект Ленина, 1", "city": None}] + db = _make_db([rows, []]) + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ) as mock_geo, + patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse, + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 1 # словил текстовый гейт (город назван в тексте) + assert res.matched == 0 + mock_parse.assert_not_called() + mock_geo.assert_not_called() + + +# ── наблюдаемость гейта: отдельный срез по колонке (#2603) ─────────────────── + + +def test_skipped_non_ekb_by_column_counts_only_column_gate() -> None: + """Колоночный и текстовый гейты различимы: by_column считает ТОЛЬКО первый. + + Общий skipped_non_ekb остаётся суммой обоих (обратная совместимость), но по + мере раскатки областных развёрток (#2598) колоночный гейт начнёт забирать + строки из no_address — без отдельного среза общий счётчик поменял бы смысл + ровно тогда, когда по нему валидируют раскатку. + """ + rows = [ + # 1. Гейт по колонке: город в колонке, в тексте адреса города НЕТ. + {"id": 300, "address": "ул. Победы, 30", "city": "Нижний Тагил"}, + # 2. Текстовый гейт: колонка пуста, город назван в тексте. + {"id": 301, "address": "г. Нижний Тагил, проспект Ленина, 1", "city": None}, + ] + db = _make_db([rows, []]) + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ), + patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house"), + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 2 + assert res.skipped_non_ekb == 2 # оба гейта, как и раньше + assert res.skipped_non_ekb_by_column == 1 # только колоночный + # Вклад текстового гейта вычисляется разностью — старая метрика #2583 жива. + assert res.skipped_non_ekb - res.skipped_non_ekb_by_column == 1 + + +def test_to_counters_exposes_skipped_non_ekb_by_column() -> None: + """Новый счётчик уходит в scrape_runs.counters, старый ключ на месте.""" + counters = BackfillCoordsResult(skipped_non_ekb=7, skipped_non_ekb_by_column=5).to_counters() + + assert counters["skipped_non_ekb"] == 7 + assert counters["skipped_non_ekb_by_column"] == 5 + # Дефолт нулевой — прогон без колоночных скипов пишет 0, а не теряет ключ. + assert BackfillCoordsResult().to_counters()["skipped_non_ekb_by_column"] == 0 + + # ── idempotency ─────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py index 2cacce98..0733d3bd 100644 --- a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py @@ -9,8 +9,10 @@ _mock_db(snapshot) helper, runs = MagicMock() assertions. DomClick-специф from __future__ import annotations +import logging 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") @@ -60,6 +62,20 @@ def _mock_db(snapshot: list[dict]) -> MagicMock: return db +def _mock_session_svc(cookies: dict[str, str] | None) -> MagicMock: + """Fake domclick_session модуль: load_session + срок годности кук (#2674). + + session_expires_at по умолчанию далеко в будущем — иначе каждый тест ловил бы + предупреждение «куки скоро протухнут». Отдельно оно проверяется в + tests/test_alerts_become_events.py. + """ + svc = MagicMock() + svc.load_session.return_value = cookies + 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: """MagicMock class whose instance is a working async context manager.""" instance = AsyncMock() @@ -88,8 +104,7 @@ async def test_backfill_empty_snapshot_marks_done() -> None: db = _mock_db([]) runs = MagicMock() fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -123,8 +138,7 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None: mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") fake_cookies = {"CAS_ID": "999", "qrator_jsid2": "abc"} - mock_svc = MagicMock() - mock_svc.load_session.return_value = fake_cookies + mock_svc = _mock_session_svc(fake_cookies) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -151,14 +165,15 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None: _, kwargs = call assert kwargs.get("cookies") == fake_cookies assert kwargs.get("browser_fetcher") is not None - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio -async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None: +async def test_backfill_cookies_none_still_proceeds_with_error_alert(caplog) -> None: """No valid session (load_session()->None) -> run still proceeds (fetch_detail - called with cookies=None), but a warning is logged so operators refresh the session. + called with cookies=None), но сигнал теперь ERROR, а не WARNING (#2674): в + скрапер-контейнере событием GlitchTip становится только ERROR. """ snapshot = _make_snapshot(1) db = _mock_db(snapshot) @@ -167,8 +182,8 @@ async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = None + mock_svc = _mock_session_svc(None) + mock_svc.session_expires_at.return_value = None # кук никогда не загружали mock_bf_cls = _mock_browser_fetcher_cls() with ( caplog.at_level("WARNING"), @@ -187,14 +202,19 @@ async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None assert result.enriched == 1 _, kwargs = mock_fetch.call_args assert kwargs.get("cookies") is None - assert "no valid DomClick session cookies" in caplog.text - runs.mark_done.assert_called_once() + assert "кук DomClick нет в БД" in caplog.text + assert [r for r in caplog.records if r.levelno >= logging.ERROR] + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_blocked_abort_after_max_consecutive() -> None: - """3 consecutive DomClickBlockedError -> abort, mark_done (NOT mark_failed). + """3 consecutive DomClickBlockedError -> abort с пометкой aborted_by_blocks (#2674). + + Раньше здесь стоял mark_done — на проде это дало 16 «успешных» прогонов подряд + с attempted=3 blocked=3 enriched=0. Теперь финал уходит в mark_backfill_finished + с флагом обрыва → статус 'banned' (ветвление проверено в test_backfill_honest_status). No IP-rotation recovery step exists for DomClick (single dedicated proxy) -- abort happens on the SAME iteration the threshold is hit, no extra recovery calls. @@ -205,8 +225,7 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None: blocked_exc = DomClickBlockedError("QRATOR challenge page detected") mock_fetch = AsyncMock(side_effect=blocked_exc) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -225,7 +244,8 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None: assert result.blocked == 3 assert result.attempted == 3 assert result.enriched == 0 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() + assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True runs.mark_failed.assert_not_called() @@ -243,8 +263,7 @@ async def test_backfill_parse_error_counts_failed_no_abort() -> None: ) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -265,7 +284,7 @@ async def test_backfill_parse_error_counts_failed_no_abort() -> None: assert result.enriched == 1 assert result.attempted == 2 assert result.blocked == 0 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @@ -281,8 +300,7 @@ async def test_backfill_sigterm_drain_breaks_and_marks_done_partial() -> None: mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -301,10 +319,10 @@ async def test_backfill_sigterm_drain_breaks_and_marks_done_partial() -> None: assert result.attempted == 1 assert result.enriched == 1 assert mock_fetch.call_count == 1 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() runs.mark_cancelled.assert_not_called() - done_counters = runs.mark_done.call_args.args[2] + done_counters = runs.mark_backfill_finished.call_args.args[2] assert done_counters["attempted"] == 1 @@ -316,8 +334,7 @@ async def test_backfill_budget_guard_stops_loop() -> None: runs = MagicMock() mono_values = iter([0.0, 999.0, 999.0]) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -330,7 +347,7 @@ async def test_backfill_budget_guard_stops_loop() -> None: await run_domclick_detail_backfill(db, run_id=7, params={"batch_size": 5, "budget_sec": 1}) mock_fetch.assert_not_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -340,8 +357,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None: db.execute.side_effect = RuntimeError("DB connection lost") runs = MagicMock() fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -355,7 +371,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None: ) runs.mark_failed.assert_called_once() - runs.mark_done.assert_not_called() + runs.mark_backfill_finished.assert_not_called() @pytest.mark.asyncio @@ -367,8 +383,7 @@ async def test_backfill_generic_exception_continues_and_rolls_back() -> None: mock_enrichment = MagicMock() mock_fetch = AsyncMock(side_effect=[RuntimeError("unexpected"), mock_enrichment]) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") - mock_svc = MagicMock() - mock_svc.load_session.return_value = {"CAS_ID": "123"} + mock_svc = _mock_session_svc({"CAS_ID": "123"}) mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -387,4 +402,4 @@ async def test_backfill_generic_exception_continues_and_rolls_back() -> None: assert result.enriched == 1 assert result.attempted == 2 db.rollback.assert_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index b8ccdc75..0e786ffb 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -342,6 +342,31 @@ async def test_geocode_missing_recent_tried_at_excluded_via_where() -> None: assert "7 days" in sql_text +@pytest.mark.asyncio +async def test_geocode_missing_select_filters_is_active() -> None: + """SELECT содержит `AND is_active` (#2604 п.1). + + На проде очередь без этого фильтра была на 98.5% забита is_active=false + объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без улицы и дома; + `ORDER BY listings_count DESC` ставил самый мусорный адрес («Новосибирская + обл.,Новосибирск», 214 listings) В НАЧАЛО очереди — весь Nominatim-бюджет + (1 req/sec) съедался мусором, до реальных активных адресов дело не доходило + (8 ночных прогонов подряд: saved=0). Falsification-проба: на коде ДО фикса + `"AND is_active" in sql_text` ложно, тест падает; после фикса проходит. + """ + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + db.execute.return_value = select_result + + with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock): + await geocode_missing_listings(db, batch_size=10) + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "AND is_active" in sql_text + + @pytest.mark.asyncio async def test_run_geocode_missing_listings_terminates_on_drained() -> None: """run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending).""" @@ -409,6 +434,242 @@ async def test_run_geocode_missing_listings_mark_failed_on_exception() -> None: mock_runs.mark_done.assert_not_called() +# ── (address, city) pair grouping / city_hint (#2594 шаг 2/3) ──────────────── + + +@pytest.mark.asyncio +async def test_geocode_missing_same_address_different_city_independent_calls_and_updates() -> None: + """Ключевой сценарий #2594: два ряда с ОДИНАКОВЫМ текстом address, но РАЗНЫМИ + city — каждый должен получить свой geocode-вызов (city_hint) и свой UPDATE, не + затрагивающий другую пару. Раньше группировка была только по address: SELECT + группировал по тексту, а UPDATE бил по WHERE address = :addr без city — второй + UPDATE (для Нижнего Тагила) перезаписал бы координаты, уже проставленные первым + (для Екатеринбурга), и наоборот. + """ + rows = [ + {"address": "ул. Победы, 30", "city": "Екатеринбург", "listings_count": 1}, + {"address": "ул. Победы, 30", "city": "Нижний Тагил", "listings_count": 1}, + ] + + ekb_geo = GeocodeResult( + lat=56.838, + lon=60.605, + full_address="Екатеринбург, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + tagil_geo = GeocodeResult( + lat=57.910, + lon=59.985, + full_address="Нижний Тагил, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_ekb = MagicMock() + update_ekb.rowcount = 1 + update_tagil = MagicMock() + update_tagil.rowcount = 1 + db.execute.side_effect = [select_result, update_ekb, update_tagil] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + side_effect=[ekb_geo, tagil_geo], + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + # 2 отдельных geocode-вызова — по одному на пару (address, city), не 1 на address. + assert mock_geo.call_count == 2 + ekb_call = mock_geo.call_args_list[0] + tagil_call = mock_geo.call_args_list[1] + assert ekb_call.args[0] == "ул. Победы, 30" + assert ekb_call.kwargs["city_hint"] == "Екатеринбург" + assert tagil_call.args[0] == "ул. Победы, 30" + assert tagil_call.kwargs["city_hint"] == "Нижний Тагил" + + # 2 отдельных UPDATE, каждый со своим city в WHERE — не задевает другую пару. + update_calls = db.execute.call_args_list[1:] + assert len(update_calls) == 2 + expected = [("Екатеринбург", 56.838), ("Нижний Тагил", 57.910)] + for call, (expected_city, expected_lat) in zip(update_calls, expected, strict=True): + sql = str(call.args[0]) + params = call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["addr"] == "ул. Победы, 30" + assert params["city"] == expected_city + assert params["lat"] == pytest.approx(expected_lat) + + assert result.addresses_processed == 2 + assert result.addresses_geocoded == 2 + assert result.listings_updated == 2 + + +@pytest.mark.asyncio +async def test_geocode_missing_null_city_group_uses_is_not_distinct_from() -> None: + """city IS NULL — своя группа. city_hint=None передаётся геокодеру, UPDATE + использует IS NOT DISTINCT FROM (обычный `=` никогда не совпал бы с NULL — + группа NULL-city вообще не обновилась бы обычным equality-сравнением).""" + rows = [{"address": "ул. Дружинина, 33", "city": None, "listings_count": 2}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + update_result.rowcount = 2 + db.execute.side_effect = [select_result, update_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=_make_geocode_result("nominatim"), + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + mock_geo.assert_called_once_with("ул. Дружинина, 33", db, city_hint=None) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + params = update_call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["city"] is None + assert result.listings_updated == 2 + + +@pytest.mark.asyncio +async def test_geocode_missing_select_groups_by_address_and_city() -> None: + """SELECT содержит GROUP BY address, city — НЕ только по address (#2594).""" + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + db.execute.return_value = select_result + + with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock): + await geocode_missing_listings(db, batch_size=10) + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "GROUP BY address, city" in sql_text + assert "SELECT address, city, COUNT(*)" in sql_text + + +@pytest.mark.asyncio +async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> None: + """Failed geocode (geo=None) для (address, city) → UPDATE tried_at ограничен + ЭТОЙ парой (IS NOT DISTINCT FROM city), не всеми строками с тем же address.""" + rows = [{"address": "несуществующий адрес", "city": "Нижний Тагил", "listings_count": 1}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=None, + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + mock_geo.assert_called_once_with("несуществующий адрес", db, city_hint="Нижний Тагил") + assert result.addresses_failed == 1 + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + params = update_call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["city"] == "Нижний Тагил" + + +# ── #2604 п.1/п.2: UPDATE decisions — locked in by test, not just comment ──── + + +@pytest.mark.asyncio +async def test_geocode_missing_success_update_not_filtered_by_is_active() -> None: + """Decision #2604 п.1 (UPDATE lat/lon): намеренно БЕЗ `is_active` в WHERE. + + Координаты — свойство физического адреса (address, city), не свойство + конкретного listing. is_active=false дубликат ЭТОЙ ЖЕ пары никогда не будет + независимо отобран SELECT'ом (он навсегда исключён оттуда) — без unfiltered + UPDATE такой дубликат остался бы с NULL lat/lon навсегда, хотя ответ уже + получен и оплачен Nominatim-вызовом активного листинга. + """ + rows = [{"address": "ул. Тестовая, 1", "city": "Екатеринбург", "listings_count": 2}] + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + update_result.rowcount = 2 + db.execute.side_effect = [select_result, update_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=_make_geocode_result("nominatim"), + ): + await geocode_missing_listings(db, batch_size=200) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + assert "is_active" not in sql + + +@pytest.mark.asyncio +async def test_geocode_missing_notfound_tried_at_update_not_filtered_by_is_active() -> None: + """Decision #2604 п.2 (geo is None → tried_at UPDATE): намеренно БЕЗ `is_active`. + + tried_at — backoff-метка для (address, city) КАК ТЕКСТА, не для конкретного + listing; is_active=false дубликат и так никогда не переотбирается SELECT'ом. + Единственный сценарий где это важно — реактивация (is_active → true) той же + строки: backoff уже стоит и корректно защищает от немедленного повтора + заведомо неудачного адреса. + """ + rows = [{"address": "несуществующий адрес", "city": None, "listings_count": 1}] + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=None, + ): + await geocode_missing_listings(db, batch_size=200) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + assert "is_active" not in sql + + +@pytest.mark.asyncio +async def test_geocode_missing_exception_tried_at_update_not_filtered_by_is_active() -> None: + """Decision #2604 п.2 (geocode() raises → tried_at UPDATE): та же логика, что и + в NOT-FOUND ветке выше — намеренно БЕЗ `is_active`, зафиксировано тестом.""" + rows = [{"address": "ул. Битая, 99", "city": None, "listings_count": 1}] + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + side_effect=RuntimeError("timeout"), + ): + await geocode_missing_listings(db, batch_size=200) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + assert "is_active" not in sql + + # ── Integration-style: estimator Avito exclusion removed ───────────────────── @@ -494,3 +755,149 @@ def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None: data = resp.json() assert "status" in data assert "addresses_total" in data + + +# ── admin.geocode_missing (per-ID endpoint) city_hint (#2594 шаг 2/3) ──────── + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", ["listings", "deals"]) +async def test_admin_geocode_missing_passes_city_hint(target: str) -> None: + """POST /admin/geocode-missing читает city из SELECT и передаёт как city_hint. + + Раньше endpoint читал только row["address"] и звал geocode(clean, db) без + города — голый тагильский адрес без города в тексте уходил в Екатеринбург. + + Оба target'а (#2603): колонка city есть и в listings (миграция 196), и в + deals (миграция 177) — SELECT + прокидывание хинта общие для обеих веток, + расходится только extra_filter, поэтому deals обязан покрываться тем же + контрактом. + """ + from app.api.v1 import admin as admin_module + + rows = [{"id": 55, "address": "ул. Победы, 30", "city": "Нижний Тагил"}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, update_result, remaining_result] + + geo = GeocodeResult( + lat=57.910, + lon=59.985, + full_address="Нижний Тагил, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + with patch( + "app.api.v1.admin.geocode", + new_callable=AsyncMock, + return_value=geo, + ) as mock_geo: + result = await admin_module.geocode_missing( + db, + limit=100, + target=target, # type: ignore[arg-type] + ) + + mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил") + assert result["geocoded"] == 1 + assert result["skipped"] == 0 + # SELECT адресован именно запрошенной таблице (обе несут колонку city). + assert f"FROM {target}" in str(db.execute.call_args_list[0][0][0]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", ["listings", "deals"]) +async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None: + """Мусорный город из колонки НЕ уходит в city_hint (#2603). + + `deals.city` росреестровое: в хвосте «Бессонова», «Билейский рыбопитомник» — + улицы/урочища, а не города. Хинт из такого значения закрывает EKB-локальные + тиры и уезжает префиксом в запрос провайдеру, т.е. хуже отсутствия хинта. + Гейт общий с двумя другими DB-колоночными callers (geocoder.known_city_hint). + """ + from app.api.v1 import admin as admin_module + + rows = [{"id": 56, "address": "ул. Бессонова, 11", "city": "Бессонова"}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, update_result, remaining_result] + + geo = GeocodeResult( + lat=56.838, + lon=60.605, + full_address="Екатеринбург, ул. Бессонова, 11", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + with patch( + "app.api.v1.admin.geocode", + new_callable=AsyncMock, + return_value=geo, + ) as mock_geo: + await admin_module.geocode_missing( + db, + limit=100, + target=target, # type: ignore[arg-type] + ) + + mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None) + + +@pytest.mark.asyncio +async def test_geocode_missing_listings_drops_junk_city_hint() -> None: + """Тот же гейт в ночной задаче: мусорный city → city_hint=None (#2603). + + Для listings.city это сегодня no-op (скрапер пишет шесть кураторских имён), + но инвариант «в geocode() уходит только словарный город» держим единым для + всех трёх DB-колоночных callers, чтобы седьмой не пришлось чинить заново. + Сырой city при этом остаётся ключом группы для UPDATE. + """ + rows = [{"address": "ул. Бессонова, 11", "city": "Бессонова", "listings_count": 2}] + db = _mock_db_rows(rows) + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=_make_geocode_result(), + ) as mock_geo: + await geocode_missing_listings(db, batch_size=10) + + mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None) + update_binds = [ + c[0][1] + for c in db.execute.call_args_list + if "UPDATE listings" in str(c[0][0]) and "SET lat" in str(c[0][0]) + ] + assert update_binds[0]["city"] == "Бессонова" # группа UPDATE — по сырому городу + + +@pytest.mark.asyncio +async def test_admin_geocode_missing_select_includes_city_column() -> None: + """SELECT в admin.geocode_missing содержит колонку city (#2594).""" + from app.api.v1 import admin as admin_module + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, remaining_result] + + with patch("app.api.v1.admin.geocode", new_callable=AsyncMock): + await admin_module.geocode_missing(db, limit=100, target="listings") + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "SELECT id, address, city" in sql_text diff --git a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py index 16c32b25..4df0d729 100644 --- a/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py @@ -541,7 +541,8 @@ async def test_run_wrapper_marks_done_and_passes_params(monkeypatch: pytest.Monk """run_newbuilding_enrich emits heartbeat → delegates with parsed params → mark_done.""" seen: dict = {} - async def _fake_backfill(_db, *, limit, force, request_delay_sec): + async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None): + # on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем. seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec) return NewbuildingEnrichBackfillResult(processed=3, succeeded=2, price_dynamics_rows=2) @@ -575,7 +576,8 @@ async def test_run_wrapper_defaults_when_params_empty(monkeypatch: pytest.Monkey """Empty default_params → limit=25, force=False, request_delay_sec=None (→ scraper delay).""" seen: dict = {} - async def _fake_backfill(_db, *, limit, force, request_delay_sec): + async def _fake_backfill(_db, *, limit, force, request_delay_sec, on_progress=None): + # on_progress — сигнал живости внутрь цикла (#2725); здесь только принимаем. seen.update(limit=limit, force=force, request_delay_sec=request_delay_sec) return NewbuildingEnrichBackfillResult() diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py index 7714ac7f..ccd3d2af 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -142,13 +142,17 @@ async def test_backfill_processes_snapshot_to_completion() -> None: assert result.enriched == 3 assert result.failed == 0 assert session.get.call_count == 3 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_parse_none_abort_after_max_consecutive() -> None: - """5 consecutive parse→None results -> abort, mark_done (NOT mark_failed).""" + """5 consecutive parse→None -> abort; финал через mark_backfill_finished (#2674). + + Раньше — mark_done; на проде ровно этот брейкер дал 31 «успешный» прогон из 52 + (attempted=5 failed=5 enriched=0). Блоков у Яндекса нет → статус 'failed'. + """ snapshot = _make_snapshot(10) db = _mock_db(snapshot) runs = MagicMock() @@ -172,7 +176,7 @@ async def test_backfill_parse_none_abort_after_max_consecutive() -> None: assert result.attempted == 5 assert result.failed == 5 assert result.enriched == 0 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @@ -205,7 +209,7 @@ async def test_backfill_parse_none_resets_on_success() -> None: assert result.attempted == 3 assert result.enriched == 1 assert result.failed == 2 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -233,7 +237,7 @@ async def test_backfill_non200_counts_as_fail_and_aborts() -> None: assert result.failed == 5 assert result.enriched == 0 - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() runs.mark_failed.assert_not_called() @@ -257,7 +261,7 @@ async def test_backfill_budget_guard_stops_loop() -> None: await run_yandex_detail_backfill(db, run_id=6, params={"batch_size": 5, "budget_sec": 1}) session.get.assert_not_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio @@ -277,7 +281,7 @@ async def test_backfill_top_level_exception_marks_failed() -> None: ) runs.mark_failed.assert_called_once() - runs.mark_done.assert_not_called() + runs.mark_backfill_finished.assert_not_called() @pytest.mark.asyncio @@ -309,7 +313,7 @@ async def test_backfill_fetch_exception_continues() -> None: assert result.attempted == 2 # fetch exception is caught by inner try/except (not DB) — no rollback needed db.rollback.assert_not_called() - runs.mark_done.assert_called_once() + runs.mark_backfill_finished.assert_called_once() @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py new file mode 100644 index 00000000..ecc31dc9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py @@ -0,0 +1,295 @@ +"""#2670: длинная серия неудач замолкает навсегда; оборванный прогон зовётся успехом. + +**1. Анти-спам, запертый в «один раз навсегда».** Оба сторожа слали алерт РОВНО на N-й +подряд неудаче и дальше молчали. Пока источники падали вперемешку с успехами, серия +рвалась и алерт взводился заново; у постоянно сломанного источника рваться нечему. +Прод 2026-08-06: + + * `avito_full_load` — 31 неудача подряд (20 failed + 28 banned + 11 cancelled в + истории), последний успешный прогон 03.07, то есть 34 дня без сбора и ровно один + алерт — на третьей неудаче; + * `avito_full_load_exhaustive` — 5 подряд; + * `domclick_city_sweep` — текущий стрик 1 при 47 завершённых прогонах: условие + прерывания у этого сторожа ДОСТИЖИМО (в отличие от #2703, где оно было + недостижимо структурно) — просто у сломанного источника оно не наступает. + +Правка: разреженная лестница напоминаний N, 2N, 4N… и не реже, чем раз в +STREAK_ALERT_MAX_PERIOD×N прогонов. + +**2. Оборванный прогон.** Признак обрыва рождался в цикле `fetch_city` по ROOM_BUCKETS +(`break` на блоке, `continue` на битом бакете) и наружу не выходил: метод возвращает +голый список лотов, а pipeline писал в `counters.pages_fetched` расчётную оценку +`бакеты × страницы` — не измерение. Поэтому прогон, прошедший 3 бакета из 6, был +неотличим от полного и уходил в `done`. Теперь охват считает сам скрейпер и несёт его +наружу тем же каналом, что и `blocked`. + +Фальсификация: на старом коде падают тесты лестницы (сторож молчал при стрике >N) и +тест неполного охвата (прогон с лотами и неполным охватом уходил в `mark_done`). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration.pipeline import run_domclick_city_sweep +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} +PFX = "scraper_kit.orchestration.pipeline" + + +def _db(rows: list[Any]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +# ── 1. Лестница напоминаний ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("streak", "due"), + [ + (0, False), + (2, False), + (3, True), # первый алерт там же, где и раньше + (4, False), + (5, False), + (6, True), # 2N + (9, False), + (12, True), # 4N + (24, True), # 8N + (31, False), # прод-стрик avito_full_load — между вехами + (48, True), # 16N, дальше лестница линейная + (96, True), + (144, True), + (150, False), + ], +) +def test_streak_alert_ladder(name: str, streak: int, due: bool) -> None: + """N, 2N, 4N, 8N… и дальше каждые STREAK_ALERT_MAX_PERIOD×N — не «ровно N».""" + mod = _MODULES[name] + assert mod._streak_alert_due(streak, 3) is due + + +# ── 2. Сторож неудач: длинная серия не замолкает ───────────────────────────── + + +def _fail_rows(streak: int, tail: int = 3) -> list[SimpleNamespace]: + """Свежие сверху: `streak` неудач подряд, затем успешные прогоны.""" + return [SimpleNamespace(status="banned") for _ in range(streak)] + [ + SimpleNamespace(status="done") for _ in range(tail) + ] + + +def _run_failure_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock: + sentry = MagicMock() + with patch.object(mod, "sentry_sdk", sentry): + mod._alert_if_consecutive_failures(_db(rows), "avito_full_load") + return sentry + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("streak", [3, 6, 12, 24, 48]) +def test_failure_watchdog_keeps_reminding(name: str, streak: int) -> None: + """На старом коде алерт был только при streak == 3; остальные вехи молчали.""" + sentry = _run_failure_watchdog(_MODULES[name], _fail_rows(streak)) + sentry.capture_message.assert_called_once() + assert f"{streak} consecutive" in sentry.capture_message.call_args[0][0] + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("streak", [0, 1, 2, 4, 31]) +def test_failure_watchdog_silent_between_milestones(name: str, streak: int) -> None: + """Анти-спам сохраняется: между вехами сторож молчит.""" + sentry = _run_failure_watchdog(_MODULES[name], _fail_rows(streak)) + sentry.capture_message.assert_not_called() + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_failure_streak_is_broken_by_success(name: str) -> None: + """Успешный прогон рвёт стрик — условие прерывания достижимо (прод: domclick, 1).""" + mod = _MODULES[name] + rows = [ + SimpleNamespace(status="banned"), + SimpleNamespace(status="banned"), + SimpleNamespace(status="done"), # рвёт: дальше 10 неудач уже не в счёт + *[SimpleNamespace(status="failed") for _ in range(10)], + ] + _run_failure_watchdog(mod, rows).capture_message.assert_not_called() + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_failure_watchdog_alerts_when_scan_window_is_full(name: str) -> None: + """Стрик длиннее окна сканирования — молчать нельзя, каким бы ни было число.""" + mod = _MODULES[name] + rows = [SimpleNamespace(status="failed") for _ in range(mod.STREAK_SCAN_LIMIT)] + _run_failure_watchdog(mod, rows).capture_message.assert_called_once() + + +# ── 3. Сторож нулей: та же лестница, семантика #2703 цела ──────────────────── + + +def _zero_rows(streak: int, tail: int = 3) -> list[SimpleNamespace]: + return [SimpleNamespace(status="done", counters={"lots_fetched": 0}) for _ in range(streak)] + [ + SimpleNamespace(status="done", counters={"lots_fetched": 42}) for _ in range(tail) + ] + + +def _run_zero_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock: + sentry = MagicMock() + with patch.object(mod, "sentry_sdk", sentry): + mod._alert_if_consecutive_zero_results(_db(rows), "cian_full_load") + return sentry + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize(("streak", "called"), [(2, False), (3, True), (6, True), (7, False)]) +def test_zero_watchdog_uses_the_same_ladder(name: str, streak: int, called: bool) -> None: + sentry = _run_zero_watchdog(_MODULES[name], _zero_rows(streak)) + assert sentry.capture_message.called is called + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_watchdog_unmeasured_still_breaks_the_streak(name: str) -> None: + """#2703 не отменяется: «не измерено» рвёт стрик, а не копит его.""" + mod = _MODULES[name] + rows = [ + SimpleNamespace(status="done", counters={"lots_fetched": 0}), + SimpleNamespace(status="done", counters={"attempted": 5}), # метрики нет + *[SimpleNamespace(status="done", counters={"lots_fetched": 0}) for _ in range(10)], + ] + _run_zero_watchdog(mod, rows).capture_message.assert_not_called() + + +# ── 4. Охват прогона доезжает из скрейпера ─────────────────────────────────── + + +class _FakeFetcher: + async def __aenter__(self) -> _FakeFetcher: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def report_ban(self, reason: str) -> None: + return None + + +@pytest.fixture +def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "scraper_kit.providers._base.build_browser_fetcher", + lambda config, source: _FakeFetcher(), + ) + + +async def test_full_sweep_reports_full_coverage(_no_browser: None) -> None: + scraper = DomClickScraper(SimpleNamespace(browser_http_endpoint="http://x:9000")) + with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)): + await scraper.fetch_city(city_id=4) + assert scraper.buckets_completed == scraper.buckets_total == len(ROOM_BUCKETS) + + +async def test_broken_bucket_is_not_counted_as_covered(_no_browser: None) -> None: + """Бакет, упавший на разборе, пропускается (continue) — это и есть обрыв охвата.""" + scraper = DomClickScraper(SimpleNamespace(browser_http_endpoint="http://x:9000")) + calls = {"n": 0} + + async def _sweep(self: DomClickScraper, **_: object) -> None: + calls["n"] += 1 + if calls["n"] in (2, 5): + raise ValueError("bad BFF shape") + + with patch.object(DomClickScraper, "_sweep_bucket", _sweep): + await scraper.fetch_city(city_id=4) + assert scraper.buckets_completed == len(ROOM_BUCKETS) - 2 + assert scraper.fetch_errors == 2 + + +# ── 5. Неполный охват перестаёт быть «успехом» ─────────────────────────────── + + +class _RunsRecorder: + def __init__(self) -> None: + self.calls: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.calls.append("update_heartbeat") + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.calls.append("mark_done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.calls.append("mark_failed") + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.calls.append("mark_banned") + + +async def _drive(*, lots_n: int, done: int, total: int, blocked: bool = False) -> list[str]: + recorder = _RunsRecorder() + lots = [MagicMock() for _ in range(lots_n)] + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=lots) + scraper.blocked = blocked + scraper.geo_filtered = 0 + scraper.fetch_errors = total - done + scraper.buckets_completed = done + scraper.buckets_total = total + with ( + patch(f"{PFX}.DomClickScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(lots_n, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=1, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + return recorder.calls + + +async def test_partial_coverage_with_lots_is_not_done() -> None: + """Прогон, прошедший 3 бакета из 6, не «успешен», даже если лоты есть. + + На старом коде эта ветка отсутствовала и прогон уходил в mark_done. + """ + assert (await _drive(lots_n=340, done=3, total=6))[-1] == "mark_failed" + + +async def test_full_coverage_with_lots_stays_done() -> None: + """Анти-оверрич: полный охват — по-прежнему done.""" + assert (await _drive(lots_n=340, done=6, total=6))[-1] == "mark_done" + + +async def test_block_still_wins_over_coverage() -> None: + """Блок проверяется раньше охвата: диагноз «нас прервали снаружи» точнее (#2657).""" + assert (await _drive(lots_n=39, done=2, total=6, blocked=True))[-1] == "mark_banned" + + +async def test_unknown_coverage_does_not_invent_a_verdict() -> None: + """Скрейпер не успел создаться (0/0) — судить об охвате нечем, ветка не срабатывает.""" + assert (await _drive(lots_n=12, done=0, total=0))[-1] == "mark_done" diff --git a/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py b/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py new file mode 100644 index 00000000..e8f8bcf2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py @@ -0,0 +1,225 @@ +"""#2674, Avito full load: окно ретроспективы под недельный такт + 503 сайдкара. + +Две находки эпика, обе — продолжающаяся потеря данных, а не история. + +1. Окно разошлось с тактом. `interval_days` (каденс, миграция 206) и + `incremental_days` (глубина обхода, миграция 129) — два независимых литерала, + которые обязаны совпадать. На проде было {interval_days: 7, incremental_days: 2}: + прогон видит listing_date в [D-2, D] = 3 суток из 7, дни D+1..D+4 не попадают + ни в один прогон. Чиним в двух местах — данными (миграция 215) и кодом + (scheduler расширяет окно до такта, чтобы расхождение не вернулось). + +2. 503 сайдкара не ретраился ни разу. `_fetch_serp_html_browser` классифицировал + "browser unavailable (proxy may be down)" как soft-ban и отправлял в бюджет + IP-ротации, а ротация снята в #2616 (max_rot=0, `_rotate_ip()` всегда False) → + условие `rot_done < max_rot` ложно всегда. Бюджет коротких backoff-retry стоял + в `else:` и для soft-ban был структурно недостижим. Итог на проде: один блип + сайдкара съедал бакет, четыре подряд — весь прогон + (`_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4`). +""" + +from __future__ import annotations + +import re +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from scraper_kit.avito_exceptions import AvitoRateLimitedError +from scraper_kit.orchestration import scheduler as kit_sched +from scraper_kit.orchestration.scheduler import ( + SchedulerContext, + _job_avito_full_load, + _job_avito_full_load_exhaustive, +) +from scraper_kit.providers.avito import serp as serp_module +from scraper_kit.providers.avito.serp import _AVITO_SIDECAR_TRANSIENT_RETRIES, AvitoScraper + +from app.services.scraper_adapters import RealScraperConfig + +_MIGRATION_215 = ( + Path(__file__).resolve().parents[1] + / "data" + / "sql" + / "215_avito_full_load_window_matches_cadence.sql" +) + + +def _ctx() -> SchedulerContext: + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=MagicMock(), + ) + + +async def _incremental_days_passed(params: dict) -> object: + """Прогнать _job_avito_full_load с params и вернуть переданный incremental_days.""" + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load(MagicMock(), 1, params, _ctx()) + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + return kwargs.get("incremental_days") + + +# ── 1. Окно ретроспективы не уже такта ──────────────────────────────────────── + + +async def test_window_widened_to_cadence() -> None: + """Прод-конфиг на момент находки: такт 7 суток, окно 2 → окно расширяется до 7. + + Фальсификация: без правки в scheduler._job_avito_full_load сюда приезжает 2 — + ровно те «двое суток из семи», о которых говорит эпик. + """ + assert await _incremental_days_passed({"interval_days": 7, "incremental_days": 2}) == 7 + + +async def test_window_wider_than_cadence_left_alone() -> None: + """Окно ШИРЕ такта — осознанный запас оператора, не сужаем.""" + assert await _incremental_days_passed({"interval_days": 3, "incremental_days": 10}) == 10 + + +async def test_daily_cadence_keeps_window() -> None: + """Back-compat: без interval_days такт = 1 сутки, окно 2 уже перекрывает его.""" + assert await _incremental_days_passed({"incremental_days": 2}) == 2 + + +async def test_null_interval_days_does_not_crash() -> None: + """`"interval_days": null` в jsonb приезжает сюда как None, а int(None) — TypeError.""" + assert await _incremental_days_passed({"interval_days": None, "incremental_days": 2}) == 2 + + +async def test_no_window_stays_exhaustive() -> None: + """Строка без incremental_days = полный обход; такт не должен её «инкрементализировать».""" + assert await _incremental_days_passed({"interval_days": 7}) is None + + +async def test_exhaustive_job_ignores_window_params() -> None: + """Соседняя джоба всегда идёт полным обходом, что бы ни лежало в её params.""" + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load_exhaustive( + MagicMock(), 1, {"interval_days": 7, "incremental_days": 2}, _ctx() + ) + _args, kwargs = mock_run.call_args + assert kwargs.get("incremental_days") is None + + +# ── 2. 503 сайдкара получает backoff-retry, а не мгновенный отказ ───────────── + + +def _sidecar_503() -> httpx.HTTPStatusError: + request = httpx.Request("POST", "http://tradein-browser:3000/fetch") + response = httpx.Response( + 503, + json={"error": "browser unavailable (proxy may be down)"}, + request=request, + ) + return httpx.HTTPStatusError("503", request=request, response=response) + + +@pytest.mark.asyncio +async def test_sidecar_503_retried_before_giving_up() -> None: + """503 «browser unavailable» — это упавший launch камуфокса, а не бан площадки. + + Сайдкар на таком отказе сам поднимает фоновый retry launch'а, поэтому повтор + через пару секунд обычно проходит. Ждём 1 попытку + весь бюджет backoff-retry. + + Фальсификация: без правки soft-ban уходит в ветку ротации (max_rot=0 → условие + ложно всегда), бюджет backoff недостижим → ровно 1 вызов fetch и немедленный + AvitoRateLimitedError. + """ + scraper = AvitoScraper(RealScraperConfig()) + assert scraper._cffi is None + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503()) + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoRateLimitedError): + await scraper._fetch_serp_html( + "https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1 + ) + + assert scraper._browser.fetch.await_count == 1 + _AVITO_SIDECAR_TRANSIENT_RETRIES + + +@pytest.mark.asyncio +async def test_sidecar_503_recovers_without_aborting_bucket() -> None: + """Один блип сайдкара больше не стоит бакета: вторая попытка отдаёт HTML.""" + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock( + side_effect=[_sidecar_503(), "serp"] + ) + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + html = await scraper._fetch_serp_html( + "https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1 + ) + + assert html == "serp" + assert scraper._browser.fetch.await_count == 2 + + +# ── 3. Миграция 215: статические инварианты ────────────────────────────────── +# Живой БД в тестах нет (см. conftest.py) — эффект UPDATE'а проверяется на проде, +# здесь фиксируем форму файла: транзакционность, отсутствие DDL, точное совпадение +# source (соседняя джоба с похожим именем ловится подстрокой), вывод значения из +# фактического interval_days вместо литерала. + + +def _sql() -> str: + return _MIGRATION_215.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat() -> str: + return re.sub(r"\s+", " ", _executable_sql()).strip().lower() + + +def test_migration_215_is_transactional_and_data_only() -> None: + flat = _flat() + assert flat.startswith("begin;") + assert flat.endswith("commit;") + for ddl in ("create ", "alter ", "drop ", "truncate "): + assert ddl not in flat, f"миграция только про данные, найден DDL: {ddl!r}" + + +def test_migration_215_targets_only_avito_full_load() -> None: + """Точное равенство source, НЕ подстрока — 'avito_full_load_exhaustive' рядом.""" + flat = _flat() + assert "where source = 'avito_full_load'" in flat + assert "avito_full_load_exhaustive" not in _executable_sql() + assert " like " not in flat + + +def test_migration_215_derives_window_from_cadence() -> None: + """Окно выводится из interval_days строки, а не хардкодится числом.""" + flat = _flat() + assert "interval_days" in flat + assert "greatest(" in flat + assert re.search(r"'incremental_days',\s*greatest", flat) is not None + + +def test_migration_215_merges_params_not_overwrites() -> None: + """`||` мерджит ключ в default_params — соседние ключи обязаны выжить.""" + flat = _flat() + assert "default_params ||" in flat or "default_params\n||" in _executable_sql().lower() + assert "set default_params = jsonb_build_object" not in flat + + +def test_migration_215_has_no_psycopg_cast_trap() -> None: + """Repo-конвенция: CAST(x AS type), не x::type.""" + assert "::" not in _executable_sql() + assert "cast(" in _flat() diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py new file mode 100644 index 00000000..ced39ac9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -0,0 +1,299 @@ +"""#2674 — показатели админки, которые не могут быть ненулевыми. + +Один класс: колонка есть, поверхность её показывает, писателя нет ни одного — и +ноль читается оператором как «всё чисто», а не как «мы это не считаем». Такой +показатель хуже отсутствующего, поэтому «честно убрать» здесь — такой же +правильный исход, как «реализовать». + +Числа с прода (tradein-postgres, 2026-08-06, до фикса): + 1. listings.is_outlier — 93 408 строк, true у 0, NULL у 0: только DEFAULT из + миграции 002, ни одного UPDATE. v_data_quality.outliers_flagged рапортовал + этот ноль как «выбросов не помечено». + 2. scrape_runs.http_requests / http_errors / returning_count / + disappeared_count — ноль во всех 3244 прогонах с миграции 015 (74 дня у + самого старого), писателя нет вообще. + 3. scrape_runs.run_type — 3244 строки, ровно одно значение 'city_sweep' + (DEFAULT из 051); таблица админки подписывала им proxy_healthcheck (1630 + прогонов), deactivate_stale_*, sber_index_pull. + 4. Фильтр источников знал три значения ('avito'/'cian'/'yandex'), а + `source = :source` — точное сравнение: строк с ТАКИМ source в таблице 0. + То есть каждый пункт фильтра давал пустую выдачу, а пустой выбор («Все») + молча подставлял source вкладки — и тоже ноль. 53 реальных источника, + 2466 прогонов (76%) вне трёх площадок, включая весь Домклик. + +БД и сеть замоканы — реального Postgres не нужно. +""" + +from __future__ import annotations + +import inspect +import os +import re +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as runs_mod + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_SQL_DIR = _BACKEND_ROOT / "data" / "sql" +_RUNS_TABLE_TSX = ( + _BACKEND_ROOT.parent / "frontend" / "src" / "components" / "scrapers" / "RunsTable.tsx" +) + +# Колонки scrape_runs без единого писателя (см. шапку). Плюс listings.is_outlier. +_DEAD_RUN_COLUMNS = ( + "run_type", + "http_requests", + "http_errors", + "returning_count", + "disappeared_count", +) + + +# ── Фейковая БД: отдаёт ровно те source'ы, что ей задали ────────────────────── + + +class _FakeRow: + def __init__(self, source: str) -> None: + self.source = source + + +class _FakeResult: + def __init__(self, rows: list[_FakeRow]) -> None: + self._rows = rows + + def fetchall(self) -> list[_FakeRow]: + return self._rows + + +class _FakeDB: + """Минимальный Session-стаб: любой execute отдаёт заданный список source'ов.""" + + def __init__(self, sources: list[str]) -> None: + self.sources = sources + self.statements: list[str] = [] + + def execute(self, stmt: Any, params: Any = None) -> _FakeResult: + self.statements.append(str(stmt)) + return _FakeResult([_FakeRow(s) for s in self.sources]) + + +@pytest.fixture +def client_factory(): + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _make(db: Any) -> TestClient: + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + return _make + + +# ══ 4. Фильтр источников берётся из данных ════════════════════════════════════ + + +def test_sources_endpoint_returns_whatever_the_table_holds(client_factory) -> None: + """Новый источник появляется в фильтре БЕЗ правки кода. + + 'domclick_city_sweep' — площадка, которой не было в захардкоженной тройке + (46 прогонов на проде нельзя было отфильтровать). 'newmarket_2031_sweep' — + заведомо несуществующий источник: если он доезжает до ответа, список + действительно приходит из данных, а не из литерала в коде. + """ + db = _FakeDB(["avito_city_sweep", "domclick_city_sweep", "newmarket_2031_sweep"]) + r = client_factory(db).get("/api/v1/admin/scrape/runs/sources") + + assert r.status_code == 200 + assert r.json()["sources"] == [ + "avito_city_sweep", + "domclick_city_sweep", + "newmarket_2031_sweep", + ] + + +def test_sources_query_ignores_run_filters() -> None: + """SELECT DISTINCT без WHERE по source/status — иначе выбор одного источника + вырезал бы из выпадающего списка все остальные.""" + sql = inspect.getsource(runs_mod.distinct_sources) + assert "DISTINCT source" in sql + assert "WHERE source = " not in sql + assert ":status" not in sql + + +def test_frontend_source_filter_has_no_hardcoded_list() -> None: + """RunsTable.tsx не содержит литерального списка площадок. + + Red на origin/main: там был `["", "avito", "cian", "yandex"] as const` — + три значения, которых в колонке source нет ни в одной из 3244 строк. + """ + tsx = _RUNS_TABLE_TSX.read_text(encoding="utf-8") + hardcoded = re.search(r'"avito"\s*,\s*"cian"\s*,\s*"yandex"', tsx) + assert hardcoded is None, ( + "список источников снова захардкожен в RunsTable.tsx — он обязан приходить " + "из /api/v1/admin/scrape/runs/sources, иначе следующая площадка опять " + "останется невидимой для оператора" + ) + assert "/scrape/runs/sources" in tsx + + +# ══ 3. run_type: вырожденная колонка ушла из API и из выдачи ══════════════════ + + +def test_unified_run_row_has_no_run_type(client_factory) -> None: + """Ответ /scrape/runs не несёт run_type — подписывать 'city_sweep' прогон + proxy_healthcheck честнее вообще нечем, кроме source.""" + from unittest.mock import patch + + row = { + "run_id": 5, + "source": "proxy_healthcheck", + "status": "done", + "params": None, + "counters": {"ok": 3}, + "total_seen": None, + "new_count": None, + "started_at": None, + "finished_at": None, + "heartbeat_at": None, + "error_text": None, + } + with patch("app.services.scrape_runs.list_all", return_value=(1, [row])): + r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs") + + assert r.status_code == 200 + body_row = r.json()["rows"][0] + assert "run_type" not in body_row + assert body_row["source"] == "proxy_healthcheck" + + +@pytest.mark.parametrize("list_all", [runs_mod.list_all, kit_runs.list_all]) +def test_list_all_selects_no_dropped_columns(list_all) -> None: + """Обе копии list_all (app.services и scraper_kit) перестали читать колонки, + которых после миграции 214 в таблице нет — иначе админка отдаёт 500.""" + sql = inspect.getsource(list_all) + still_there = [c for c in _DEAD_RUN_COLUMNS if re.search(rf"\b{c}\b", sql)] + assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}" + + +# ══ 4b. Побочка починки фильтра: кнопка отмены открылась на все источники ═════ + + +def test_cancel_flag_true_only_for_jobs_that_poll_cancellation() -> None: + """honors_cancel = ровно те source'ы, чей джоб опрашивает runs.is_cancelled. + + 'yandex_newbuilding_sweep' в наборе НЕ должен быть, хотя и *_sweep: его таск + (app/tasks/yandex_newbuilding_sweep.py) отмену не опрашивает — поэтому правило + не может быть «любой sweep». + """ + honoring = [ + "avito_city_sweep", + "cian_city_sweep_nizhniy_tagil", + "domclick_city_sweep", + "avito_full_load_exhaustive", + "cian_full_load", + "avito_newbuilding_sweep", + "rosreestr_dkp_import", + ] + ignoring = [ + "proxy_healthcheck", + "deactivate_stale_avito", + "refresh_search_matview", + "sber_index_pull", + "yandex_newbuilding_sweep", + "house_imv_backfill", + ] + assert [s for s in honoring if not runs_mod.honors_cancel(s)] == [] + assert [s for s in ignoring if runs_mod.honors_cancel(s)] == [] + + +def test_row_carries_cancellable_so_ui_hides_the_button(client_factory) -> None: + """Строка отдаёт cancellable — без него UI показал бы «Отменить» у любого + running-прогона, включая proxy_healthcheck (1631 из 3245).""" + from unittest.mock import patch + + base = { + "status": "running", + "params": None, + "counters": None, + "total_seen": None, + "new_count": None, + "started_at": None, + "finished_at": None, + "heartbeat_at": None, + "error_text": None, + } + rows = [ + {"run_id": 1, "source": "avito_city_sweep", **base}, + {"run_id": 2, "source": "proxy_healthcheck", **base}, + ] + with patch("app.services.scrape_runs.list_all", return_value=(2, rows)): + r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs") + + assert r.status_code == 200 + assert [row["cancellable"] for row in r.json()["rows"]] == [True, False] + + +def test_mark_cancelled_refuses_non_cooperating_source() -> None: + """Гейт на общем узле всех пяти ручек отмены: 'cancelled' у задачи, которая + отмену не опрашивает, — это враньё в статусе И снятие has_running_run-guard + (второй свип на том же прокси → бан, инцидент 2026-05-31).""" + db = MagicMock() + db.execute.return_value.fetchone.return_value = MagicMock(source="proxy_healthcheck") + + assert runs_mod.mark_cancelled(db, 42) is False + # UPDATE не выполнялся — только SELECT source. + assert db.execute.call_count == 1 + db.commit.assert_not_called() + + +# ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════ + + +def _migration_214() -> str: + matches = sorted(_SQL_DIR.glob("214_*.sql")) + assert matches, "не найдена миграция 214_* (удаление мёртвых показателей #2674)" + return matches[0].read_text(encoding="utf-8") + + +def test_migration_drops_every_dead_column() -> None: + """Все пять колонок scrape_runs + listings.is_outlier дропаются идемпотентно.""" + sql = _migration_214() + missing = [c for c in _DEAD_RUN_COLUMNS if f"DROP COLUMN IF EXISTS {c}" not in sql] + assert not missing, f"миграция не дропает: {missing}" + assert "DROP COLUMN IF EXISTS is_outlier" in sql + + +def test_latest_v_data_quality_no_longer_reports_outliers() -> None: + """Действующее определение v_data_quality (последняя миграция, которая его + создаёт) не упоминает is_outlier. + + Red на origin/main: там последним был 095_dead_schema.sql со строкой + `(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` + — показатель, который не мог быть ненулевым, потому что колонку не писал никто. + + Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой + DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту + миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический: + деплой применяет файлы отсортированными, последний по имени — последний в проде. + """ + marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b") + creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8"))) + assert creators, "не найдено ни одной миграции, создающей v_data_quality" + latest = creators[-1].read_text(encoding="utf-8") + hit = marker.search(latest) + assert hit is not None + body = latest[hit.end() :].split(";")[0] + assert "outliers_flagged" not in body + assert "is_outlier" not in body diff --git a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py new file mode 100644 index 00000000..188b185d --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py @@ -0,0 +1,431 @@ +"""#2674 — писатели наконец пишут то, что обещает схема. + +Три находки одного класса: колонка есть, писатель есть, тест на писателя зелёный, +а данные не появляются. Обычный юнит-тест такое не ловит по построению — он +проверяет то, что автор себе представлял. Ловится это сверкой «что схема обещает» +с «что писатель реально перечисляет», поэтому тесты ниже читают миграции и +сравнивают их с SQL писателя, а не повторяют его же список колонок. + +Числа с прода на 2026-08-05/06 (до фикса): + 1. house_suggestions — 25 055 строк, image_link/area_m2/rooms/floor/total_floors + заполнены у 0 из них (колонки с миграции 064, ~74 дня). + 2. listings_snapshots.status — 'active' у всех 394 704 строк при 55 448 реально + неактивных объявлений; ни 'closed', ни 'stale' не писал никто и никогда. + 3. listing_source_events — 8288 строк, все price_change; edited/first_seen — + ноль за всё время. + +Отдельный класс тестов — гейты на то, что писатель НЕ пишет: delisted/relisted схема +разрешает, но при покрытии обхода 10-35% они неотличимы от «скрейпер снова дошёл» +(контроль — домклик со 100% покрытием: 0 возвратов за 5 суток), а TTL-путь не имеет +права называть протухание снятием. Журнал и история из догадок хуже пустых. + +БД и сеть замоканы — реального Postgres не нужно. +""" + +from __future__ import annotations + +import inspect +import json +import os +import re +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.avito.imv import _parse_suggestion + +from app.services import house_imv_backfill as hib +from app.tasks import deactivate_stale_avito as deact_mod +from app.tasks import listing_source_snapshot as snap_mod + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +# ── Общие хелперы: схема vs writer ──────────────────────────────────────────── + + +def _declared_columns(migration: str, table: str) -> set[str]: + """Имена колонок из CREATE TABLE IF NOT EXISTS ( ... ); в миграции.""" + body = migration.split(f"CREATE TABLE IF NOT EXISTS {table} (")[1].split("\n);")[0] + cols: set[str] = set() + for line in body.splitlines(): + m = re.match(r"\s+([a-z_]+)\s+[a-z]", line) + if m: + cols.add(m.group(1)) + return cols + + +def _insert_columns(sql: str, table: str) -> set[str]: + """Имена колонок из INSERT INTO
( ... ) VALUES.""" + m = re.search(rf"INSERT INTO {table}\s*\(([^)]*)\)", sql, re.S) + assert m is not None, f"не найден INSERT INTO {table}" + return {c.strip() for c in m.group(1).split(",") if c.strip()} + + +# ══ 1. Фотографии подсказок Avito IMV ═════════════════════════════════════════ + + +def test_suggestion_parser_keeps_image_link() -> None: + """imageLink из ответа площадки доезжает до модели, а не выбрасывается.""" + sugg = _parse_suggestion( + { + "id": 8000753763, + "title": "3-к. квартира, 61,6 м², 1/5 эт.", + "price": 7400000, + "imageLink": "https://80.img.avito.st/image/1/abc", + } + ) + assert sugg.image_link == "https://80.img.avito.st/image/1/abc" + # В raw_payload ссылку по-прежнему не дублируем — у неё теперь своя колонка. + assert "imageLink" not in (sugg.raw_payload or {}) + + +def test_suggestion_parser_derives_metrics_from_title() -> None: + """rooms/area_m2/floor/total_floors парсятся из title тем же путём, что у + placementHistory (колонки house_suggestions существуют с миграции 064).""" + sugg = _parse_suggestion( + {"id": 1, "title": "3-к. квартира, 61,6 м², 1/5 эт.", "price": 7400000} + ) + assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (3, 61.6, 1, 5) + + +def test_suggestion_parser_survives_unparsable_title() -> None: + """Нераспознанный заголовок → None'ы, а не исключение (строка всё равно пишется).""" + sugg = _parse_suggestion({"id": 2, "title": "Апартаменты", "price": 1}) + assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (None, None, None, None) + + +def test_studio_title_keeps_area_and_floors() -> None: + """Студия не роняет разбор целиком: 1991 заголовок из 25 055 (7.9%) — без комнатности. + + Обязательная группа комнатности обнуляла ВСЕ ЧЕТЫРЕ поля, хотя площадь и этажность + в заголовке есть. rooms=0 — конвенция kit'а («0 = студия»), а не «неизвестно». + """ + sugg = _parse_suggestion( + {"id": 3, "title": "Квартира-студия, 34,2 м², 9/10 эт.", "price": 3_500_000} + ) + assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (0, 34.2, 9, 10) + + +def test_placement_history_gets_same_title_fix() -> None: + """Тот же регексп чинит второго писателя — house_placement_history (8.8% без площади).""" + from scraper_kit.providers.avito.imv import _parse_placement_item + + item = _parse_placement_item({"id": 9, "title": "Квартира-студия, 28 м², 2/17 эт."}) + assert (item.rooms, item.area_m2, item.floor, item.total_floors) == (0, 28.0, 2, 17) + + +def test_live_fixture_suggestions_carry_image_link() -> None: + """Живой capture avito_imv_getdata.json: у подсказок реально есть imageLink.""" + data = json.loads((_FIXTURES / "avito_imv_getdata.json").read_text("utf-8")) + items = data["suggestions"]["items"] + parsed = [_parse_suggestion(raw) for raw in items] + assert parsed, "фикстура без подсказок — тест бессмыслен" + assert all(s.image_link for s in parsed) + + +def test_house_suggestions_insert_covers_every_declared_column() -> None: + """Regression-гейт на весь класс бага: INSERT обязан перечислять КАЖДУЮ колонку + house_suggestions из миграции 064 (кроме автоинкрементного id). + + Именно этот тест покраснел бы 74 дня назад: image_link (и заодно area_m2/rooms/ + floor/total_floors) объявлены схемой, но в запрос вставки не входили — 25 055 + строк с NULL. Тест не дублирует список колонок писателя, а сверяет его со схемой, + поэтому ловит и следующую забытую колонку. + """ + declared = _declared_columns( + (_SQL_DIR / "064_house_imv_phase_c.sql").read_text("utf-8"), "house_suggestions" + ) + written = _insert_columns(inspect.getsource(hib.save_imv_result), "house_suggestions") + assert ( + declared - {"id"} <= written + ), f"колонки без писателя: {sorted(declared - {'id'} - written)}" + + +def test_save_imv_result_binds_image_link_and_metrics() -> None: + """save_imv_result передаёт значения подсказки в bind-параметры (не только в SQL).""" + sugg = _parse_suggestion( + { + "id": 777, + "title": "2-к. квартира, 42 м², 4/5 эт.", + "price": 6300000, + "imageLink": "https://img/x.jpg", + } + ) + result = MagicMock( + cache_key="k", + recommended_price=1, + lower_price=1, + higher_price=1, + market_count=1, + raw_response=None, + placement_history=[], + suggestions=[sugg], + ) + params = { + "rooms": 2, + "area_m2": 42.0, + "floor": 4, + "floor_at_home": 5, + "house_type": "panel", + "renovation_type": "cosmetic", + "has_balcony": True, + "has_loggia": False, + } + db = MagicMock() + hib.save_imv_result(db, house_id=1, params=params, result=result) + + sugg_calls = [ + c for c in db.execute.call_args_list if "INSERT INTO house_suggestions" in str(c.args[0]) + ] + assert len(sugg_calls) == 1 + bound = sugg_calls[0].args[1] + assert bound["img"] == "https://img/x.jpg" + assert (bound["rooms"], bound["area"], bound["floor"], bound["total_floors"]) == (2, 42.0, 4, 5) + + +# ══ 2. «Снято» и «протухло» в дневной истории объявлений ══════════════════════ + +_DEACT_SQL_BUILDERS = (deact_mod._build_all_segments_sql, deact_mod._build_segments_sql) + + +@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS) +@pytest.mark.parametrize("column", sorted(deact_mod._ALLOWED_STALENESS_COLUMNS)) +def test_deactivation_writes_stale_snapshot_in_same_statement(build: Any, column: str) -> None: + """Деактивация и снимок 'stale' — один statement, значит одна транзакция. + + До #2674 задача только двигала флаг: в listings_snapshots не появлялось ничего, + и дата снятия объявления (лучший сигнал «скорее всего продано») восстанавливалась + на глаз из последнего показа + предполагаемого срока жизни. + """ + sql = str(build(column).text) + assert "SET is_active = false" in sql + assert "RETURNING id, price_rub" in sql + assert "INSERT INTO listings_snapshots" in sql + assert "'stale'" in sql + # Снимок пишется по строкам, которые вернул сам UPDATE, — не отдельной выборкой. + assert "FROM stale" in sql + # Идемпотентность: повторный прогон в те же сутки не падает на PK. + assert "ON CONFLICT (listing_id, snapshot_date) DO UPDATE" in sql + # psycopg v3: никаких :param::type. + assert not re.search(r":\w+::", sql) + + +@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS) +def test_ttl_path_never_claims_closed(build: Any) -> None: + """TTL-путь НЕ имеет права писать 'closed' — он не знает, что объявление снято. + + Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток + против 12 суток простоя обхода). Под статусом 'closed' это 6131 фальшивая «дата + продажи» одной датой. 'closed' остаётся только за 404 — там ответила площадка. + """ + assert "'closed'" not in str(build("last_seen_at").text) + + +class _FakeResult: + def __init__(self, rowcount: int) -> None: + self.rowcount = rowcount + + +class _FakeDB: + def __init__(self, rowcount: int = 0) -> None: + self._rowcount = rowcount + self.executed: list[tuple[Any, Any]] = [] + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + self.executed.append((stmt, params)) + return _FakeResult(self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: # pragma: no cover — путь ошибки тут не проверяется + pass + + +def test_deactivate_stale_listings_threads_run_id_into_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_id доезжает до снимка — провенанс «каким прогоном закрыто» не теряется.""" + monkeypatch.setattr(deact_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(deact_mod.runs_mod, "mark_failed", lambda *a, **k: None) + db = _FakeDB(rowcount=7) + + out = deact_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + run_id=555, + listing_source="avito", + ttl_days=10, + ) + + assert out == {"deactivated": 7} + assert len(db.executed) == 1, "деактивация и снимок обязаны быть одним statement'ом" + stmt, bound = db.executed[0] + assert "INSERT INTO listings_snapshots" in str(stmt) + assert bound is not None and bound["run_id"] == 555 + + +@pytest.mark.asyncio +async def test_avito_404_records_closed_snapshot() -> None: + """404 с площадки — самый достоверный сигнал снятия; он тоже попадает в историю.""" + from scraper_kit.avito_exceptions import AvitoListingGoneError + + from app.tasks.avito_detail_backfill import run_avito_detail_backfill + + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = [ + {"id": 42, "source_url": "/items/42", "price_rub": 5_000_000} + ] + db.execute.return_value = sel + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False) + + with ( + patch("app.tasks.avito_detail_backfill.settings", fake_settings), + patch("app.tasks.avito_detail_backfill.AsyncSession", return_value=AsyncMock()), + patch("app.tasks.avito_detail_backfill.AvitoScraper"), + patch("app.tasks.avito_detail_backfill.runs_mod", MagicMock()), + patch("app.tasks.avito_detail_backfill.asyncio.sleep", new_callable=AsyncMock), + patch( + "app.tasks.avito_detail_backfill.fetch_detail", + AsyncMock(side_effect=AvitoListingGoneError("404 gone")), + ), + patch("app.tasks.avito_detail_backfill.upsert_listing_snapshot") as snap, + ): + result = await run_avito_detail_backfill(db, run_id=3, params={"budget_sec": 60}) + + assert result.gone == 1 + snap.assert_called_once() + assert snap.call_args.kwargs["status"] == "closed" + assert snap.call_args.kwargs["listing_id"] == 42 + assert snap.call_args.kwargs["price_rub"] == 5_000_000 + + +# ══ 3. Журнал событий объявлений ══════════════════════════════════════════════ + + +def _schema_event_types() -> set[str]: + """Пять типов из CHECK-констрейнта миграции 079 — источник правды.""" + sql = (_SQL_DIR / "079_listing_source_history.sql").read_text("utf-8") + check = sql.split("event_type IN (")[1].split(")")[0] + return set(re.findall(r"'([a-z_]+)'", check)) + + +# Два типа схемы НЕ ВЫВОДИМЫ из наших данных и намеренно не пишутся (#2674). +# is_active в снимке значит «мы видели», а не «есть на площадке», поэтому переход +# рождается тем, что скрейпер снова дошёл до источника. Контрольная группа за 14-18.07: +# domklik при покрытии 99.9-100% дал возвратов РОВНО 0 и снятий 1-4 в сутки, yandex при +# 34-43% — снятий 343-433 в сутки. Тот же обход, тот же день, разница только в покрытии. +# Отсюда: avito 13.07 (остановка обхода) 3023 «снятия» за сутки против контрольных 1-4 +# (точность ≈4%), и 4705 «возвратов» из 5493 за 12 дней — два дня после возобновления. +_NOT_DERIVABLE_EVENT_TYPES = {"delisted", "relisted"} + + +def test_event_writer_covers_every_derivable_schema_event_type() -> None: + """Писатель обязан уметь каждый ВЫВОДИМЫЙ тип из CHECK схемы. + + До #2674 из пяти типов писался один (price_change, 8288 строк). Дописаны два + выводимых; два оставшихся — сознательное решение, а не забытая ветка (см. + _NOT_DERIVABLE_EVENT_TYPES). Тест сверяет со схемой, а не с копией списка, + поэтому покраснеет и на шестом типе, добавленном в CHECK без писателя. + """ + declared = _schema_event_types() + assert len(declared) == 5, f"схема 079 изменилась: {sorted(declared)}" + sql = str(snap_mod._EVENT_DIFF_SQL.text) + expected = declared - _NOT_DERIVABLE_EVENT_TYPES + missing = {t for t in expected if f"'{t}'" not in sql} + assert not missing, f"выводимые типы без писателя: {sorted(missing)}" + + +def test_not_derivable_events_are_never_written() -> None: + """delisted/relisted не пишутся: при покрытии обхода 10-35% они неотличимы от + «скрейпер снова дошёл». Контроль — домклик со 100% покрытием: 0 возвратов за 5 суток. + + Гейт против «дописать для полноты»: журнал из догадок хуже пустого журнала. + """ + sql = str(snap_mod._EVENT_DIFF_SQL.text) + written = {t for t in _NOT_DERIVABLE_EVENT_TYPES if f"'{t}'" in sql} + assert not written, f"невыводимые типы попали в писатель: {sorted(written)}" + # is_active больше не читается вовсе — иначе ветка вернётся незаметно. + assert "is_active" not in sql + + +def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() -> None: + """Ветки выводятся из полей снимка, first_seen достижим только через LEFT JOIN. + + С обычным JOIN источник без предыдущего снимка отбрасывался джойном — событие + «первое появление» было недостижимо по построению. + """ + sql = str(snap_mod._EVENT_DIFF_SQL.text) + assert "LEFT JOIN LATERAL" in sql + assert "p.snapshot_date IS NULL" in sql # first_seen + assert "t.payload_hash IS DISTINCT FROM p.payload_hash" in sql # edited + # Снимок за сегодня обязан отдавать поля, из которых выводятся ветки. + assert "SELECT listing_source_id, price_rub, payload_hash" in sql + + +def test_event_dedup_works_across_runs_not_only_within_one() -> None: + """change_time усечён до суток: UNIQUE(source, change_time, type) должен гасить + повторный прогон в те же сутки (2 августа их было два), а не только строки одного.""" + sql = str(snap_mod._EVENT_DIFF_SQL.text) + assert "date_trunc('day', now())" in sql + + +def test_price_change_division_guarded_by_nullif() -> None: + """VALUES вычисляется ДО фильтра e.fires → без NULLIF прогон падал бы на + первом источнике с нулевой прошлой ценой (предикат p.price_rub <> 0 не спасает).""" + sql = str(snap_mod._EVENT_DIFF_SQL.text) + assert "NULLIF(p.price_rub, 0)" in sql + + +class _EventFakeDB: + """Session-заглушка: SET LOCAL и snapshot дают rowcount, event-diff — пары счётчиков.""" + + def __init__(self, event_rows: list[tuple[str, int]]) -> None: + self._event_rows = event_rows + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + if "listing_source_events" in str(stmt): + result.fetchall.return_value = self._event_rows + else: + result.rowcount = 100 + return result + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: # pragma: no cover — путь ошибки тут не проверяется + pass + + +def test_counters_report_every_written_type_including_zeros( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Счётчики прогона показывают все пишущиеся типы; не сработавший честно равен 0. + + Ровно этого счётчика не хватало, чтобы заметить четыре нуля из пяти за 66 дней. + Счётчиков НЕвыводимых типов быть не должно — иначе вечный 0 будет читаться как + «событий не было», а не как «мы это сознательно не пишем». + """ + monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) + + db = _EventFakeDB([("first_seen_events", 600), ("edited_events", 17)]) + out = snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type] + + assert out["first_seen_events"] == 600 + assert out["edited_events"] == 17 + assert out["price_change_events"] == 0 + # Ключ на каждый пишущийся тип — иначе «ноль» неотличим от «типа нет в counters». + for event_type in _schema_event_types() - _NOT_DERIVABLE_EVENT_TYPES: + assert f"{event_type}_events" in out + for event_type in _NOT_DERIVABLE_EVENT_TYPES: + assert f"{event_type}_events" not in out diff --git a/tradein-mvp/backend/tests/test_2678_house_type_canon.py b/tradein-mvp/backend/tests/test_2678_house_type_canon.py new file mode 100644 index 00000000..c349a222 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2678_house_type_canon.py @@ -0,0 +1,128 @@ +"""#2678: тип дома приводится к канону У ИСТОЧНИКА, и словарь ровно один. + +Кейсы взяты не из головы, а из фактического замера прода 2026-08-06 +(`SELECT source, house_type, count(*) FROM houses GROUP BY 1,2`): + + monolithBrick 56 · other 18 · stalin 3 · aerocreteBlock 1 · + gasSilicateBlock 1 · wireframe 1 · плюс канон (brick/panel/monolith/ + monolith_brick/block/wood) и 8440 NULL. + +Проверяется три вещи: + 1. каждый фактический вариант → канон (или честный None); + 2. живой писатель houses.house_type (avito-каталог) больше не изобретает + 'other' и ходит через общий нормализатор; + 3. миграция 224 не заводит третий словарь — её CASE совпадает с картой кода. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from scraper_kit.house_type_normalizer import _RAW_TO_CANON, normalize_house_type +from scraper_kit.providers.avito.houses import _normalize_house_type as avito_house_type + +_MIGRATION_224 = ( + Path(__file__).resolve().parents[1] / "data" / "sql" / "224_houses_house_type_canon.sql" +) + +# Фактический словарь houses.house_type на проде 2026-08-06 → чем он обязан стать. +# None = «честно неизвестно» (NULL нейтрален для soft-penalty эстиматора, в отличие +# от выдуманного материала). +_PROD_VALUES: list[tuple[str, str | None]] = [ + ("monolithBrick", "monolith_brick"), # 56 строк + ("other", None), # 18 строк — источник сказал «другое», материала нет + ("stalin", "brick"), # 3 строки — «сталинка» = кирпич (решение миграции 141) + ("aerocreteBlock", "block"), # 1 строка + ("gasSilicateBlock", "block"), # 1 строка + ("wireframe", None), # 1 строка — каркас, в каноне такого материала нет + ("brick", "brick"), + ("panel", "panel"), + ("monolith", "monolith"), + ("monolith_brick", "monolith_brick"), + ("block", "block"), + ("wood", "wood"), +] + + +@pytest.mark.parametrize(("raw", "expected"), _PROD_VALUES) +def test_prod_value_maps_to_canon(raw: str, expected: str | None) -> None: + assert normalize_house_type(raw) == expected + + +def test_canon_survives_uppercase_including_monolith_brick() -> None: + """#2678 п.6: сквозной проброс канона был регистрозависим — кроме monolith_brick. + + Значений в верхнем регистре в базе сегодня ноль; это страховка на новый источник, + который отдаст канон «как в документации». + """ + assert normalize_house_type("MONOLITH_BRICK") == "monolith_brick" + assert normalize_house_type("Monolith_Brick") == "monolith_brick" + # Остальной канон и раньше переживал регистр — фиксируем, что не сломали. + for token in ("BRICK", "Panel", "MONOLITH", "Block", "WOOD"): + assert normalize_house_type(token) == token.lower() + + +# ── живой писатель houses.house_type: avito-каталог домов ──────────────────────── + + +@pytest.mark.parametrize( + ("label", "expected"), + [ + ("Монолитно-кирпичный", "monolith_brick"), + ("Панельный", "panel"), + ("КИРПИЧНЫЙ", "brick"), + (" Блочный ", "block"), + ("Деревянный", "wood"), + ], +) +def test_avito_house_label_maps_to_canon(label: str, expected: str) -> None: + assert avito_house_type(label) == expected + + +def test_avito_unknown_label_is_null_not_other() -> None: + """Незнакомая подпись → NULL. До #2678 здесь появлялось 'other'. + + 'other' всегда != канону, т.е. читатель получал не «неизвестно», а гарантированное + несовпадение: ложный штраф при подборе аналогов и пропуск оценки. + """ + assert avito_house_type("Саманный") is None + assert avito_house_type("") is None + assert avito_house_type(None) is None + + +def test_avito_writer_handles_foreign_vocabulary() -> None: + """Писатель ходит через общий нормализатор, а не только через свою карту.""" + assert avito_house_type("monolithBrick") == "monolith_brick" + assert avito_house_type("MONOLIT_BRICK") == "monolith_brick" + + +# ── миграция 224: тот же словарь, что в коде ───────────────────────────────────── + + +def _migration_case_pairs() -> dict[str, str]: + """WHEN 'x' THEN 'y' из исполняемой части миграции (без `--`-комментариев).""" + code = "\n".join( + line.split("--", 1)[0] for line in _MIGRATION_224.read_text(encoding="utf-8").splitlines() + ) + return dict(re.findall(r"WHEN\s+'([^']+)'\s+THEN\s+'([^']+)'", code)) + + +def test_migration_224_mapping_matches_code() -> None: + """Миграция не заводит третий словарь — каждая пара есть в _RAW_TO_CANON.""" + pairs = _migration_case_pairs() + assert pairs, "в миграции 224 не нашлось ни одного WHEN ... THEN" + for raw, canon in pairs.items(): + assert _RAW_TO_CANON.get(raw) == canon, f"{raw!r} расходится с house_type_normalizer" + + +def test_migration_224_touches_only_mapped_tokens() -> None: + """WHERE ограничен теми же токенами → 'other'/'wireframe'/канон не трогаются.""" + code = "\n".join( + line.split("--", 1)[0] for line in _MIGRATION_224.read_text(encoding="utf-8").splitlines() + ) + where_tokens = set(re.findall(r"'([A-Za-z]+)'", code.split("WHERE", 1)[1].split(";", 1)[0])) + assert where_tokens == set(_migration_case_pairs()) + assert "other" not in where_tokens + assert "wireframe" not in where_tokens diff --git a/tradein-mvp/backend/tests/test_2686_ban_kind_split.py b/tradein-mvp/backend/tests/test_2686_ban_kind_split.py new file mode 100644 index 00000000..1b4e1121 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2686_ban_kind_split.py @@ -0,0 +1,246 @@ +"""#2686: статус 'banned' перестаёт смешивать наш сбой с блокировкой площадкой. + +Замер на проде (2026-08-06): из 115 avito-прогонов со статусом «забанен» 92 (80%) — +отказ НАШЕГО браузерного сайдкара («browser unavailable (proxy may be down)»), и +только 10 — реальный firewall площадки. По этому статусу приняли решение замедлить +avito_full_load_exhaustive более чем вдвое (миграция 206) — основание было ложным. + +Различитель несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения), а не разбирается +из текста ошибки постфактум. Проверяем три звена цепочки: + 1. serp.py поднимает AvitoSidecarUnavailableError там и только там, где виноваты мы; + 2. pipeline.ban_kind_of_exception переводит тип в диагноз; + 3. run_avito_full_load доносит диагноз до mark_banned — и при ЭТОМ сохраняет + done_buckets-чекпоинт (побочная функция 'banned', которую нельзя потерять). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.avito_exceptions import ( + AvitoBlockedError, + AvitoRateLimitedError, + AvitoSidecarUnavailableError, +) +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration.pipeline import ( + ban_kind_of_exception, + run_avito_full_load, +) +from scraper_kit.providers.avito import serp as serp_module +from scraper_kit.providers.avito.serp import AvitoScraper + +from app.services.scraper_adapters import RealScraperConfig + +PFX = "scraper_kit.orchestration.pipeline" + +# Настоящий firewall-интерстициал Авито: _is_firewall_page ловит его по тексту. +_FIREWALL_HTML = "Доступ ограничен: проблема с IP" + + +def _sidecar_503() -> httpx.HTTPStatusError: + """Ровно то, что прод писал 90 раз с 05.07 по 03.08.""" + request = httpx.Request("POST", "http://tradein-browser:3000/fetch") + response = httpx.Response( + 503, json={"error": "browser unavailable (proxy may be down)"}, request=request + ) + return httpx.HTTPStatusError("503", request=request, response=response) + + +# ── 1. Место порождения ────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_sidecar_failure_raises_own_infra_subtype() -> None: + """503 сайдкара → AvitoSidecarUnavailableError, и он ОСТАЁТСЯ AvitoRateLimitedError. + + Фальсификация: до правки поднимался голый AvitoRateLimitedError — первый assert + падает. Второй assert стережёт совместимость: все существующие + `except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить + этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт. + """ + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503()) + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoSidecarUnavailableError) as excinfo: + await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) + + assert isinstance(excinfo.value, AvitoRateLimitedError) + + +@pytest.mark.asyncio +async def test_platform_firewall_is_not_our_infra() -> None: + """Рендер firewall-страницы → AvitoBlockedError, и это НЕ наш инфра-подтип.""" + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock(return_value=_FIREWALL_HTML) + scraper._browser.report_ban = MagicMock() # sync-хук, не корутина + + with pytest.raises(AvitoBlockedError) as excinfo: + await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) + + assert not isinstance(excinfo.value, AvitoSidecarUnavailableError) + + +# ── 2. Перевод типа в диагноз ──────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("exc", "expected"), + [ + (AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA), + (AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM), + (AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM), + (RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_PLATFORM), + ], +) +def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None: + """Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип.""" + assert ban_kind_of_exception(exc) == expected + + +# ── 3. Диагноз доезжает до строки прогона, чекпоинт цел ────────────────────── + + +class _BanRecorder: + """Минимальный двойник scrape_runs: интересуют ban_kind и counters у mark_banned.""" + + def __init__(self) -> None: + self.banned: list[tuple[str, dict[str, Any]]] = [] + self.other: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + pass + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.other.append("mark_done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.other.append("mark_failed") + + def mark_banned( + self, + db: Any, + run_id: int, + error: str, + counters: dict[str, Any], + *, + ban_kind: str = kit_runs.BAN_KIND_PLATFORM, + ) -> None: + self.banned.append((ban_kind, dict(counters))) + + +def _config() -> SimpleNamespace: + return SimpleNamespace( + scraper_fetch_mode="curl_cffi", + browser_http_endpoint="http://browser.test/fetch", + scraper_proxy_url=None, + avito_proxy_max_rotations=0, + avito_serp_ok_not_banned=True, + avito_proxy_rotate_settle_s=0.0, + proxy_rotate_attempts=1, + proxy_rotate_attempt_timeout_s=1.0, + avito_full_load_price_cap=1400, + avito_full_load_max_pages=100, + avito_full_load_concurrency=1, + request_delay_sec=0.0, + scraper_skip_seen_today=False, + ) + + +def _scraper_that_saves_one_bucket_then(exc: Exception) -> MagicMock: + """Fake AvitoScraper: один бакет успешно отдан в on_bucket, затем отказ.""" + + async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None: + on_bucket("2к:0-5m", [MagicMock(source_id="a1")]) + raise exc + + m = MagicMock() + m.__aenter__ = AsyncMock(return_value=m) + m.__aexit__ = AsyncMock(return_value=None) + m.fetch_all_secondary = _fetch + m._browser = None + return m + + +async def _drive_full_load(exc: Exception) -> _BanRecorder: + recorder = _BanRecorder() + with ( + patch(f"{PFX}.AvitoScraper", return_value=_scraper_that_saves_one_bucket_then(exc)), + patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_avito_full_load(MagicMock(), run_id=1, config=_config(), matcher=MagicMock()) + return recorder + + +@pytest.mark.asyncio +async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None: + """Отказ нашего сайдкара → ban_kind='infra', done_buckets сохранён. + + Фальсификация: до правки mark_banned не принимал ban_kind вовсе — вызов падал + бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы + 'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206. + """ + recorder = await _drive_full_load( + AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)") + ) + + assert len(recorder.banned) == 1 + ban_kind, counters = recorder.banned[0] + assert ban_kind == kit_runs.BAN_KIND_INFRA + assert counters["done_buckets"] == ["2к:0-5m"] + + +@pytest.mark.asyncio +async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None: + """Блокировка площадкой → ban_kind='platform', чекпоинт сохраняется ТАК ЖЕ. + + Второй исход не должен потерять побочную функцию 'banned' при разведении. + """ + recorder = await _drive_full_load(AvitoBlockedError("Avito SERP firewall — IP banned")) + + assert len(recorder.banned) == 1 + ban_kind, counters = recorder.banned[0] + assert ban_kind == kit_runs.BAN_KIND_PLATFORM + assert counters["done_buckets"] == ["2к:0-5m"] + + +# ── 4. Диагноз доходит до SQL ──────────────────────────────────────────────── + + +@pytest.mark.parametrize("module_name", ["kit", "app"]) +@pytest.mark.parametrize( + ("kwargs", "expected"), + [({}, "platform"), ({"ban_kind": "infra"}, "infra")], +) +def test_mark_banned_writes_ban_kind( + module_name: str, kwargs: dict[str, str], expected: str +) -> None: + """Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform').""" + if module_name == "kit": + mod: Any = kit_runs + else: + from app.services import scrape_runs as app_runs + + mod = app_runs + + db = MagicMock() + mod.mark_banned(db, 1, "err", {"lots_fetched": 0}, **kwargs) + + sql, params = db.execute.call_args_list[0][0] # первый UPDATE, не alert-запрос после + assert "ban_kind = :ban_kind" in str(sql) + assert params["ban_kind"] == expected diff --git a/tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py b/tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py new file mode 100644 index 00000000..6c3d64cb --- /dev/null +++ b/tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py @@ -0,0 +1,114 @@ +"""#2698 — домовая оценка Авито ходила в сайдкар без прокси пула, и причина отказа терялась. + +Два независимых дефекта одного пути (backfill_house_imv → BrowserFetcher → POST +/fetch-json → tradein-browser): + +1. `BrowserFetcher(source="avito", endpoint=...)` конструировался БЕЗ + proxy_provider/use_pool/environment — единственный avito-путь без проводки пула + (avito_city_sweep её подключает, orchestration/pipeline.py). Тело POST уходило без + "proxy", сайдкар брал env-прокси SCRAPER_PROXY_URL = узел пула id=1 + (provider_affinity='domclick'), который `proxy_pool.acquire('avito')` не выдал бы + никогда. Прод 03.07-05.08: 35 отказов из 35 попыток в каждом прогоне при живом + сайдкаре и работающих в те же дни sweep'ах. + +2. Причина отказа приходила в теле ответа сайдкара ({"error": "browser unavailable + (proxy may be down)"} / "Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"), а + `resp.raise_for_status()` её выбрасывал — в houses.imv_error_reason 34 дня лежал + голый код статуса. + +Сеть/БД/камуфокс замоканы. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.browser_fetcher import BrowserFetcher + +from app.services import house_imv_backfill as hib + +# ── (1) проводка пула на пути домовой оценки ───────────────────────────────── + + +class _CapturingFetcher: + captured: ClassVar[dict[str, Any]] = {} + + def __init__(self, **kwargs: Any) -> None: + _CapturingFetcher.captured = kwargs + + async def __aenter__(self) -> _CapturingFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +async def test_backfill_browser_fetcher_gets_proxy_pool_wiring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Прод-случай #2698: без этих трёх аргументов сайдкар уходил на env-прокси.""" + _CapturingFetcher.captured = {} + monkeypatch.setattr(hib.settings, "avito_imv_use_browser_fetcher", True) + monkeypatch.setattr(hib.settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(hib.settings, "environment", "production") + monkeypatch.setattr(hib, "BrowserFetcher", _CapturingFetcher) + monkeypatch.setattr(hib, "_process_one_house", AsyncMock(return_value="ok")) + + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [ + {"id": 1, "address": "ЕКБ, ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6} + ] + await hib.backfill_house_imv(db, batch_size=1) + + captured = _CapturingFetcher.captured + assert captured["source"] == "avito" + assert captured["proxy_provider"] is not None, "без провайдера пул не подключится" + assert captured["use_pool"] is True, "флаг пула должен доезжать до фетчера" + # #2616 шаг 1: иначе прод-отказ «пул пуст» мёртв и мы молча уходим на env-прокси. + assert captured["environment"] == "production" + + +# ── (2) причина отказа сайдкара доезжает до вызывающего ────────────────────── + + +def _client_returning(status: int, body: dict[str, Any], url: str) -> MagicMock: + resp = httpx.Response(status, json=body, request=httpx.Request("POST", url)) + client = MagicMock() + client.post = AsyncMock(return_value=resp) + client.aclose = AsyncMock(return_value=None) + return client + + +async def test_fetch_json_error_carries_sidecar_reason() -> None: + """503 сайдкара: в тексте ошибки должна быть ПРИЧИНА, а не только код статуса.""" + endpoint = "http://tradein-browser:3000" + async with BrowserFetcher(source="avito", endpoint=endpoint) as bf: + bf._client = _client_returning( # type: ignore[assignment] + 503, {"error": "browser unavailable (proxy may be down)"}, f"{endpoint}/fetch-json" + ) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await bf.fetch_json("https://www.avito.ru/web/1/coords/by_address?address=X") + + message = str(exc_info.value) + assert "browser unavailable (proxy may be down)" in message + assert "503" in message + + +async def test_fetch_error_carries_sidecar_reason() -> None: + """Тот же инвариант для /fetch — общий helper, а не заплатка на одном вызове.""" + endpoint = "http://tradein-browser:3000" + async with BrowserFetcher(source="avito", endpoint=endpoint) as bf: + bf._client = _client_returning( # type: ignore[assignment] + 500, {"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}, f"{endpoint}/fetch" + ) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await bf.fetch("https://www.avito.ru/evaluation/realty") + + assert "NS_ERROR_PROXY_BAD_GATEWAY" in str(exc_info.value) diff --git a/tradein-mvp/backend/tests/test_2702_run_timestamps.py b/tradein-mvp/backend/tests/test_2702_run_timestamps.py new file mode 100644 index 00000000..99e2792f --- /dev/null +++ b/tradein-mvp/backend/tests/test_2702_run_timestamps.py @@ -0,0 +1,237 @@ +"""#2702: отметки времени прогона не охватывают его работу. + +Что было. Финализаторы писали `finished_at`/`heartbeat_at` через `now()`, а `now()` +в PostgreSQL — синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции. +Выполняются финализаторы той же сессией, что и работа задачи, поэтому если рабочая +транзакция всё это время оставалась открытой, их UPDATE попадал ВНУТРЬ неё и получал +время НАЧАЛА работы. + +Прод-замер 2026-08-06 (487 прогонов с finished_at и counters.duration_sec): + * 153 — заявленная длительность больше окна finished_at − started_at в >1.5 раза; + * 133 — окно меньше секунды при работе дольше 10 с, причём 126 из них укладываются + в 9-64 мс: столько проходит от коммита claim'а до первого запроса рабочей + транзакции — подпись механизма, а не разброс. +Крайний случай, воспроизведённый ниже дословно: прогон 346 (cian_history_backfill) — +18 230 с работы, окно 32 мс. + +Почему дефект был не сплошной: он зависел от того, коммитила ли задача перед финалом. +`cadastral_geo_match` / `house_imv_backfill` / `avito_detail_backfill` коммитят +поштучно — у них окно совпадало с работой; `yandex_address_backfill` (45 из 50 +прогонов), `newbuilding_enrich`, `cian_history_backfill` — нет. + +Фальсификация: на старом коде (`now()`) тесты 1 и 3 дают другой ответ. +""" + +from __future__ import annotations + +import inspect +import os +import re +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration import scheduler as kit_scheduler + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} + +# Колонки, которые обязаны нести НАСТОЯЩЕЕ время, а не время старта транзакции. +_TS_COLS = ("started_at", "finished_at", "heartbeat_at") + + +def _split_top(items: str) -> list[str]: + """Разбить список SQL-элементов по запятым ВЕРХНЕГО уровня (CAST(:x AS t) — один).""" + out: list[str] = [] + depth = 0 + cur = "" + for ch in items: + if ch == "," and depth == 0: + out.append(cur.strip()) + cur = "" + continue + depth += (ch == "(") - (ch == ")") + cur += ch + out.append(cur.strip()) + return out + + +class _Row: + """Строка ответа: id для create_run, source для alert-хука.""" + + id = 1 + source = "src" + status = "done" + + +class _FakeResult: + def fetchone(self) -> _Row: + return _Row() + + def first(self) -> _Row: + return _Row() + + def fetchall(self) -> list[_Row]: + return [] + + +class _FakePg: + """Мини-модель PostgreSQL на две функции времени и ленивую транзакцию. + + `now()` == `transaction_timestamp()` — замерзает на старте транзакции; + `clock_timestamp()` — настоящие часы. Транзакция открывается лениво на первом + execute (autobegin SQLAlchemy) и закрывается commit/rollback. Больше модель + ничего не умеет — этого достаточно, чтобы отличить одно от другого. + """ + + def __init__(self) -> None: + self.wall: float = 0.0 # «стенные часы» теста + self.tx_start: float | None = None + self.row: dict[str, float] = {} # что осело в scrape_runs + self.calls: list[str] = [] + + def _stamp(self, col: str, func: str) -> None: + assert self.tx_start is not None + self.row[col] = self.tx_start if func.lower() == "now" else self.wall + + def execute(self, stmt: Any, params: Any = None) -> _FakeResult: + if self.tx_start is None: + self.tx_start = self.wall + self.calls.append("execute") + sql = str(stmt) + for col in _TS_COLS: # UPDATE ... SET = () + m = re.search(rf"\b{col}\s*=\s*(now|clock_timestamp)\s*\(\s*\)", sql, re.I) + if m is not None: + self._stamp(col, m.group(1)) + ins = re.search( + r"INSERT INTO scrape_runs\s*\((.*?)\).*?VALUES\s*\((.*)\)", sql, re.S | re.I + ) + if ins is not None: # INSERT — отметки времени позиционные, в VALUES + for col, val in zip(_split_top(ins.group(1)), _split_top(ins.group(2)), strict=False): + f = re.fullmatch(r"(now|clock_timestamp)\s*\(\s*\)", val, re.I) + if col in _TS_COLS and f is not None: + self._stamp(col, f.group(1)) + return _FakeResult() + + def commit(self) -> None: + self.calls.append("commit") + self.tx_start = None + + def rollback(self) -> None: + self.calls.append("rollback") + self.tx_start = None + + +# ── 1. Финал прогона датируется концом работы, а не стартом транзакции ──────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_finished_at_covers_the_work_not_the_transaction_start(name: str) -> None: + """Прогон 346 дословно: 18 230 с работы в одной незакоммиченной транзакции. + + На старом коде finished_at = 0.032 (старт рабочей транзакции) → окно 32 мс при + пяти часах работы. Это и есть та строка, ради которой заведена задача. + """ + mod = _MODULES[name] + db = _FakePg() + db.row["started_at"] = 0.0 # create_run уже закоммитил claim + + db.wall = 0.020 + mod.update_heartbeat(db, 1, {"listings_processed": 0}) # heartbeat + commit + + db.wall = 0.032 + db.execute("SELECT id FROM listings WHERE history IS NULL") # рабочая транзакция + + db.wall = 18230.0 # пять часов работы, ни одного коммита + mod.mark_done(db, 1, {"listings_processed": 1200, "duration_sec": 18230}) + + assert db.row["finished_at"] == pytest.approx(18230.0) + assert db.row["heartbeat_at"] == pytest.approx(18230.0) + window = db.row["finished_at"] - db.row["started_at"] + assert window == pytest.approx(18230.0), "окно прогона обязано охватывать его работу" + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("finalizer", ["mark_failed", "mark_banned"]) +def test_failed_and_banned_finals_are_wall_clock_too(name: str, finalizer: str) -> None: + """Тот же инвариант для неуспешных финалов. + + Их спасал defensive-rollback в начале (он закрывал рабочую транзакцию), но + полагаться на побочный эффект чужой защиты нельзя — проверяем явно. + """ + mod = _MODULES[name] + db = _FakePg() + db.wall = 0.019 + db.execute("SELECT 1") # рабочая транзакция открыта + db.wall = 1460.0 + getattr(mod, finalizer)(db, 1, "boom", {"checked": 0, "duration_sec": 1460}) + assert db.row["finished_at"] == pytest.approx(1460.0) + + +# ── 2. started_at переживает откат рабочей транзакции ──────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_started_at_survives_rolled_back_work_transaction(name: str) -> None: + """Требование #2702 п.1: отметка старта живёт в СВОЕЙ закоммиченной транзакции. + + create_run коммитит INSERT до возврата run_id, а ни один финализатор прогона + started_at не переписывает — поэтому откат рабочей транзакции его не достаёт. + Финал при этом обязан быть датирован концом работы (это и падает на старом коде). + """ + mod = _MODULES[name] + db = _FakePg() + + run_id = mod.create_run(db, source="cian_history_backfill", params={}) + assert run_id == 1 + assert db.calls == ["execute", "commit"], "INSERT прогона обязан коммититься сразу" + + db.wall = 0.030 + db.execute("UPDATE listings SET address = 'x'") # рабочая транзакция + db.wall = 100.0 + db.rollback() # работа упала и откатилась + + db.wall = 100.5 + db.execute("SELECT count(*) FROM listings") # новая рабочая транзакция + db.wall = 1460.0 + mod.mark_done(db, 1, {"checked": 0, "duration_sec": 1460}) + + assert db.row["started_at"] == pytest.approx(0.0), "started_at не должен сдвигаться" + assert db.row["finished_at"] == pytest.approx(1460.0) + + +# ── 3. Инвариант источника: никакая отметка времени не пишется now() ───────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_no_run_timestamp_is_written_with_now(name: str) -> None: + """`now()` в этих модулях не имеет корректного применения — его быть не должно. + + Проверяем весь исходник, а не отдельные запросы: INSERT в create_run пишет + started_at/heartbeat_at позиционно (в VALUES), и построчная проверка его бы + пропустила — ровно так дефект и дожил до 3 300 прогонов. + """ + src = inspect.getsource(_MODULES[name]) + # Регистрозависимо: SQL в этих модулях пишется в верхнем регистре, а строчное + # `now()` встречается в объяснительной прозе docstring'ов — ловим SQL, не текст. + assert re.search(r"\bNOW\s*\(\s*\)", src) is None + assert "clock_timestamp()" in src + + +def test_zombie_criterion_compares_real_clocks() -> None: + """#2702 п.2: поиск зависших сравнивает записанный heartbeat со «сейчас». + + Обе стороны сравнения обязаны быть настоящим временем: на проде у всех 6 + прогонов cian_history_backfill, помеченных 'zombie', записанный heartbeat так и + остался на отметке старта (max advance 0.0 с) — критерий решал по замороженной + отметке, хотя нормальный прогон этого источника длится до 5.06 ч. + """ + src = inspect.getsource(kit_scheduler.reap_zombies) + stmt = re.search(r"UPDATE scrape_runs.*?RETURNING id", src, re.S) + assert stmt is not None + assert re.search(r"\bNOW\s*\(\s*\)", stmt.group(0)) is None + assert stmt.group(0).count("clock_timestamp()") == 2 # finished_at + порог сравнения diff --git a/tradein-mvp/backend/tests/test_2703_zero_result_watchdog.py b/tradein-mvp/backend/tests/test_2703_zero_result_watchdog.py new file mode 100644 index 00000000..0620d534 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2703_zero_result_watchdog.py @@ -0,0 +1,169 @@ +"""#2703: сторож «ноль результатов» перестаёт читать «не измерено» как ноль. + +`_alert_if_consecutive_zero_results` смотрел колонку `scrape_runs.total_seen`, у +которой DEFAULT 0. Замер на проде (2026-08-06, 53 источника): + + * 21 источник / 455 прогонов — колонку заполняет `lots_fetched` (все city-sweep'ы); + * 4 источника / 133 прогона — пишут `unique_fetched` (full-load'ы avito/cian/yandex), + сторож их НЕ видел, хотя у `cian_full_load` 6 из 38 успешных прогонов дали ноль; + * 28 источников / 2650 прогонов — не пишут НИ ОДНОГО результатного ключа + (`proxy_healthcheck` 1650, три detail-backfill'а, `deactivate_stale_*`, + `refresh_search_matview` с буквально пустыми `{}` во всех 55 строках …). + +Для последних сторож читал 0 ВСЕГДА, в том числе у полностью успешного прогона: +стрик не прерывался никогда, а анти-спам «один раз на стрик» превращался в «один +раз навсегда». Ноль записей `total_seen` в counters на всей истории — проверено. + +Правка: признак берётся из counters, «не измерено» (None) стрик ПРЕРЫВАЕТ. +""" + +from __future__ import annotations + +import logging +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} + + +def _row(status: str, counters: dict[str, Any] | None) -> SimpleNamespace: + """Строка scrape_runs. + + `total_seen` кладём НАРОЧНО: старый сторож читал именно её, поэтому на строках + без результатного ключа он видел ноль и копил ложный стрик. Тесты ниже + фальсифицируются ровно этим — на старом коде они дают другой ответ. + """ + return SimpleNamespace(status=status, counters=counters, total_seen=0) + + +def _db(rows: list[SimpleNamespace]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +def _run_watchdog(mod: Any, rows: list[SimpleNamespace]) -> MagicMock: + """Прогнать сторож на заданных строках, вернуть мок sentry_sdk.""" + sentry = MagicMock() + with patch.object(mod, "sentry_sdk", sentry): + mod._alert_if_consecutive_zero_results(_db(rows), "src") + return sentry + + +# ── 1. Различие «не измерено» и «измеренный ноль» ──────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("counters", "expected"), + [ + ({"lots_fetched": 0}, 0), + ({"lots_fetched": 12}, 12), + ({"total_seen": 7}, 7), + ({"unique_fetched": 0}, 0), # full-load'ы — раньше сторож их не видел + ({"unique_fetched": 340}, 340), + ({"attempted": 1600, "enriched": 0, "failed": 1600}, None), # backfill + ({}, None), # refresh_search_matview + (None, None), + ], +) +def test_run_result_count(name: str, counters: dict[str, Any] | None, expected: int | None) -> None: + """1600 попыток без результатного ключа — это None, а не ноль и не 1600.""" + assert _MODULES[name]._run_result_count(counters) == expected + + +# ── 2. Слепая зона больше не копит ложный стрик ────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_source_without_result_metric_never_alerts(name: str, caplog: Any) -> None: + """Backfill: три успешных прогона подряд, результатной метрики нет → тишина + лог. + + Фальсификация: старый сторож читал total_seen=0 у всех трёх → отправлял алерт + и запирал анти-спам навсегда. Здесь sentry не вызывается вовсе, а слепая зона + названа вслух в логе (раньше её признаком был вечно молчащий сторож). + """ + mod = _MODULES[name] + mod._warn_source_has_no_result_metric.cache_clear() + rows = [_row("done", {"attempted": 1600, "enriched": 0}) for _ in range(4)] + + with caplog.at_level(logging.WARNING): + sentry = _run_watchdog(mod, rows) + + sentry.capture_message.assert_not_called() + assert "zero-result watchdog неприменим" in caplog.text + assert "attempted, enriched" in caplog.text + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_unmeasured_run_breaks_the_streak(name: str) -> None: + """Два измеренных нуля + прогон без метрики → стрик прерван, алерта нет. + + Фальсификация: на старом коде все три строки имели total_seen=0 → стрик + считался полным и алерт уходил. Это и есть «условие прерывания недостижимо»: + теперь оно достижимо по построению — любой немеряный прогон его выполняет. + """ + mod = _MODULES[name] + mod._warn_source_has_no_result_metric.cache_clear() + rows = [ + _row("done", {"lots_fetched": 0}), + _row("done", {"lots_fetched": 0}), + _row("done", {"deactivated": 3}), # чужой словарь → не измерено + _row("done", {"lots_fetched": 5}), + ] + + assert _run_watchdog(mod, rows).capture_message.call_count == 0 + + +# ── 3. Настоящая деградация по-прежнему видна ──────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize("key", ["lots_fetched", "unique_fetched"]) +def test_real_zero_streak_still_alerts(name: str, key: str) -> None: + """Три измеренных нуля подряд после нормального прогона → алерт. + + `unique_fetched` (full-load'ы) добавлен этой правкой: раньше эти 4 источника + (133 прогона) сторож не видел вовсе. Фальсификация для обоих ключей: старый + сторож читал r.total_seen, которого в SELECT больше нет — на новых строках он + падал бы в свой best-effort except и молчал. + """ + mod = _MODULES[name] + mod._warn_source_has_no_result_metric.cache_clear() + rows = [_row("done", {key: 0}) for _ in range(3)] + [_row("done", {key: 42})] + + assert _run_watchdog(mod, rows).capture_message.call_count == 1 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_anti_spam_still_holds_within_a_streak(name: str) -> None: + """Четвёртый ноль подряд повторного алерта не даёт — анти-спам не сломан.""" + mod = _MODULES[name] + mod._warn_source_has_no_result_metric.cache_clear() + rows = [_row("done", {"lots_fetched": 0}) for _ in range(4)] + + assert _run_watchdog(mod, rows).capture_message.call_count == 0 + + +# ── 4. Колонка total_seen тоже начинает видеть full-load'ы ─────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_column_counts_picks_unique_fetched(name: str) -> None: + """`_column_counts` разделяет словарь со сторожем — одна функция, два потребителя. + + Побочная польза: admin/observability перестаёт показывать total_seen=0 у + full-load'ов (avito 70 прогонов, cian 52, yandex 4, exhaustive 7). + """ + assert _MODULES[name]._column_counts({"unique_fetched": 340})[0] == 340 + assert _MODULES[name]._column_counts({"attempted": 5})[0] is None diff --git a/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py new file mode 100644 index 00000000..a0450aa2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py @@ -0,0 +1,225 @@ +"""#2725: сигнал живости слался один раз — до батча, — и живые прогоны reap'ились. + +Что было. `_execute_cian_backfill` дёргал `update_heartbeat` ровно один раз, ДО +`backfill_cian_history()`, а сам батч (до 100 объявлений + 37 домов, каждое — fetch +через браузер + пауза ~5 с) heartbeat не трогал. `reap_zombies` меряет именно +`heartbeat_at` с порогом ZOMBIE_THRESHOLD_HOURS = 6 ч → прогон помечался 'zombie' +строго на 6-м часу независимо от того, работает он или висит. + +Прод-замер 2026-08-06: 6 прогонов `cian_history_backfill` со статусом 'zombie', у всех +шести сдвиг heartbeat 16-32 мс (= единственный стартовый вызов) и финал ровно на +started_at + 6.00 ч. Живыми они при этом были: внутри окна пятерых писались строки +offer_price_history с source='cian' (98/523/38/60/83 — плановый писатель этих строк +только этот батч), у прогона 304 последняя строка легла через 5.40 ч после старта. +Штатная длительность источника доходит до 5.06 ч (прогон 346, counters.duration_sec +18230) — то есть источник ходит вплотную к порогу. + +Почему чинится сигнал, а не критерий: пометка 'zombie' снимает running-блокировку +источника (`has_running_run` видит только status='running'), и без неё зависший +прогон запер бы источник навсегда. Плюс `mark_done` апдейтит `WHERE status='running'`, +поэтому после ложной пометки собственный финал прогона — no-op (отсюда нулевые +counters у всех шести строк). + +Фальсификация: на старом коде тест 2 падает — планировщик не передавал `on_progress`, +батч heartbeat не двигал, и к концу 7-часовой работы возраст сигнала = 7 ч > 6 ч. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.scheduler import ZOMBIE_THRESHOLD_HOURS + +from app.services import scheduler as sched_mod +from app.tasks import cian_history_backfill + + +def _would_be_reaped(heartbeat_at: datetime, now: datetime) -> bool: + """Критерий reap_zombies дословно: heartbeat старше порога → 'zombie'.""" + return heartbeat_at < now - timedelta(hours=ZOMBIE_THRESHOLD_HOURS) + + +class _FakeBrowserFetcher: + def __init__(self, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _FakeBrowserFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +# ── 1. Батч сообщает о продвижении на каждой сущности ──────────────────────── +async def test_batch_reports_progress_per_entity() -> None: + db = MagicMock() + db.execute.return_value.mappings.return_value.all.side_effect = [ + [ + {"id": 1, "source_url": "https://cian.ru/1"}, + {"id": 2, "source_url": "https://cian.ru/2"}, + ], + [{"id": 10, "cian_zhk_url": "https://cian.ru/zhk-10"}], + ] + seen: list[tuple[int, int]] = [] + + with ( + patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher), + patch.object( + cian_history_backfill, + "fetch_detail", + AsyncMock(return_value=SimpleNamespace(price_changes=[])), + ), + patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()), + patch( + "scraper_kit.providers.cian.newbuilding.fetch_newbuilding", + AsyncMock(return_value=SimpleNamespace()), + ), + patch("scraper_kit.providers.cian.newbuilding.save_newbuilding_enrichment", MagicMock()), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + result = await cian_history_backfill.backfill_cian_history( + db, + do_listings=True, + do_houses=True, + do_valuations=False, + on_progress=lambda r: seen.append((r.listings_processed, r.houses_processed)), + ) + + # По одному сигналу на каждую сущность обоих блоков, счётчики растут. + assert seen == [(1, 0), (2, 0), (2, 1)] + assert result.listings_processed == 2 + assert result.houses_processed == 1 + + +# ── 2. Долгий прогон с продвигающимся heartbeat не помечается зависшим ─────── +async def test_long_run_with_advancing_heartbeat_is_not_reaped() -> None: + """7 часов работы, час на сущность: планировщик обязан двигать heartbeat.""" + t0 = datetime(2026, 6, 26, 4, 47, tzinfo=UTC) + clock = SimpleNamespace(now=t0) + beats: list[datetime] = [] + + async def _fake_batch(db: Any, **kwargs: Any) -> Any: + on_progress = kwargs.get("on_progress") + result = cian_history_backfill.CianBackfillResult() + for _ in range(7): # 7 сущностей по часу — дольше 6-часового порога + clock.now += timedelta(hours=1) + result.listings_processed += 1 + if on_progress is not None: + on_progress(result) + result.duration_sec = 7 * 3600 + return result + + fake_runs = SimpleNamespace( + update_heartbeat=lambda db, run_id, counters: beats.append(clock.now), + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _fake_batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={}) + + assert len(beats) == 8, "стартовый сигнал + по одному на сущность" + reaped = _would_be_reaped(beats[-1], clock.now) + assert not reaped, "живой прогон с продвигающимся heartbeat не должен reap'иться" + assert fake_runs.mark_done.called + + +# ── 3. Прогон без продвижения — помечается (контроль критерия) ─────────────── +async def test_long_run_without_advancing_heartbeat_is_reaped() -> None: + """Тот же прогон, но батч сигнала не шлёт — критерий обязан сработать.""" + t0 = datetime(2026, 6, 26, 4, 47, tzinfo=UTC) + clock = SimpleNamespace(now=t0) + beats: list[datetime] = [] + + async def _mute_batch(db: Any, **kwargs: Any) -> Any: + clock.now += timedelta(hours=7) # работает, но молча + return cian_history_backfill.CianBackfillResult() + + fake_runs = SimpleNamespace( + update_heartbeat=lambda db, run_id, counters: beats.append(clock.now), + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _mute_batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={}) + + assert beats == [t0], "единственный сигнал — стартовый" + assert _would_be_reaped(beats[-1], clock.now) + + +# ── 4. Сбой heartbeat не роняет уже идущую работу ──────────────────────────── +async def test_heartbeat_failure_does_not_abort_the_batch() -> None: + processed: list[int] = [] + calls = {"n": 0} + + def _flaky_heartbeat(db: Any, run_id: int, counters: dict[str, int]) -> None: + calls["n"] += 1 + if calls["n"] > 1: # стартовый прошёл, дальше БД отвалилась + raise Exception("DB gone") + + async def _fake_batch(db: Any, **kwargs: Any) -> Any: + on_progress = kwargs["on_progress"] + result = cian_history_backfill.CianBackfillResult() + for _ in range(3): + result.listings_processed += 1 + on_progress(result) # обязан проглотить исключение внутри себя + processed.append(result.listings_processed) + return result + + fake_runs = SimpleNamespace( + update_heartbeat=_flaky_heartbeat, + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _fake_batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=1, params={}) + + assert processed == [1, 2, 3] + assert fake_runs.mark_done.called + + +# ── 5. Тот же дефект у newbuilding_enrich — сигнал прокинут ────────────────── +async def test_newbuilding_enrich_passes_progress_callback() -> None: + from app.tasks import newbuilding_enrich_backfill as nb + + beats: list[dict[str, int]] = [] + + async def _fake_backfill(db: Any, **kwargs: Any) -> Any: + on_progress = kwargs.get("on_progress") + assert on_progress is not None, "планировщик обязан прокинуть сигнал живости" + result = nb.NewbuildingEnrichBackfillResult() + result.processed += 1 + on_progress(result) + return result + + fake_runs = SimpleNamespace( + update_heartbeat=lambda db, run_id, counters: beats.append(counters), + mark_done=MagicMock(), + mark_failed=MagicMock(), + ) + + with ( + patch.object(nb, "runs_mod", fake_runs), + patch.object(nb, "backfill_newbuilding_enrichment", _fake_backfill), + ): + await nb.run_newbuilding_enrich(MagicMock(), run_id=1, params={}) + + assert len(beats) == 2, "стартовый сигнал + сигнал из середины цикла" + assert beats[-1]["processed"] == 1 diff --git a/tradein-mvp/backend/tests/test_781_quality_gate.py b/tradein-mvp/backend/tests/test_781_quality_gate.py index 25748f2e..ac689067 100644 --- a/tradein-mvp/backend/tests/test_781_quality_gate.py +++ b/tradein-mvp/backend/tests/test_781_quality_gate.py @@ -54,10 +54,15 @@ def _make_listing_qa(*, price_per_m2: float, area_m2: float = 60.0) -> dict[str, } +# #oblast-E: 5 items (>= HEADLINE_LISTINGS_MIN_N) so the new headline +# sufficiency gate doesn't suppress this fixture's median (still 210_000, +# symmetric) before the anchor-vs-radius mechanic under test runs. _RADIUS_ANALOGS_QA: list[dict[str, Any]] = [ - _make_listing_qa(price_per_m2=200_000.0), + _make_listing_qa(price_per_m2=195_000.0), + _make_listing_qa(price_per_m2=205_000.0), _make_listing_qa(price_per_m2=210_000.0), - _make_listing_qa(price_per_m2=220_000.0), + _make_listing_qa(price_per_m2=215_000.0), + _make_listing_qa(price_per_m2=225_000.0), ] # 2 comps -- below min_comps=4 threshold introduced by #755. @@ -160,10 +165,10 @@ def test_755_anchor_n2_does_not_fire_headline_stays_radius() -> None: f"Expected radius median 210_000, got {est.median_price_per_m2} -- " "anchor with n=2 comps must NOT fire (min_comps=4 post-#755)" ) - # Confidence from 3 radius analogs must not be "high" (n_analogs=3 < threshold). + # Confidence from 5 radius analogs must not be "high" (unique_addr < 7 threshold). assert ( est.confidence != "high" - ), f"Confidence should not be 'high' with 3 radius analogs, got {est.confidence!r}" + ), f"Confidence should not be 'high' with 5 radius analogs, got {est.confidence!r}" def test_755_anchor_n2_pure_unit_confidence_never_high() -> None: diff --git a/tradein-mvp/backend/tests/test_admin_proxies.py b/tradein-mvp/backend/tests/test_admin_proxies.py index 277edf0b..8726e69c 100644 --- a/tradein-mvp/backend/tests/test_admin_proxies.py +++ b/tradein-mvp/backend/tests/test_admin_proxies.py @@ -1,9 +1,13 @@ -"""Offline-тесты admin proxy-pool ручек (#2161). +"""Offline-тесты admin proxy-pool ручек (#2161, #2610). Покрытие (db мокается, NO live network/DB): - POST /api/v1/admin/proxies/bulk — UPSERT-счётчики, валидация affinity/kind - - GET /api/v1/admin/proxies — маскировка пароля, фильтры + - GET /api/v1/admin/proxies — маскировка пароля, фильтры, disabled_reason в ответе, + активные баны по источникам (#2600 п.2 — узел бывает enabled=true и при этом не + выдаётся конкретному источнику) - PATCH /api/v1/admin/proxies/{id} — enable/disable, 404 + - #2610: PATCH enabled=false ставит disabled_reason (ручное выключение отличимо от + авто); PATCH enabled=true сбрасывает disabled_reason в NULL (снова авто-восстанавливаем) """ from __future__ import annotations @@ -47,6 +51,28 @@ def _scalar_result(value: object) -> MagicMock: return res +def _cleared_bans_result(rows: list[dict[str, Any]] | None = None) -> MagicMock: + """Ответ на DELETE ... RETURNING source (proxy_pool.clear_source_bans, #2600 п.2). + + PATCH enabled=true снимает баны узла по источникам — «ручное включение = чистый + лист», как и обнуление disabled_reason рядом. + """ + res = MagicMock() + res.fetchall.return_value = [type("Row", (), r)() for r in (rows or [])] + return res + + +def _bans_result(rows: list[dict[str, Any]] | None = None) -> MagicMock: + """Ответ на ВТОРОЙ execute в /proxies-ручках — активные баны по источникам (#2600 п.2). + + list_proxies/patch_proxy после основного запроса дочитывают scrape_proxy_source_bans + (_fetch_source_bans), поэтому мок обязан отдавать два разных результата по порядку. + """ + res = MagicMock() + res.mappings.return_value.all.return_value = rows or [] + return res + + # ── _mask_proxy_url unit ───────────────────────────────────────────────────── @@ -133,6 +159,7 @@ def _proxy_db_row(**over: Any) -> dict[str, Any]: "provider_affinity": "avito", "rotate_url": "http://user:secret@rot:9000/changeip", "enabled": True, + "disabled_reason": None, "consecutive_fails": 0, "exit_ip": "1.2.3.4", "latency_ms": 120, @@ -153,7 +180,7 @@ def _proxy_db_row(**over: Any) -> dict[str, Any]: def test_list_masks_password(client: TestClient, db: MagicMock) -> None: result = MagicMock() result.mappings.return_value.all.return_value = [_proxy_db_row()] - db.execute.return_value = result + db.execute.side_effect = [result, _bans_result()] r = client.get("/api/v1/admin/proxies") assert r.status_code == 200, r.text @@ -164,6 +191,53 @@ def test_list_masks_password(client: TestClient, db: MagicMock) -> None: assert "secret" not in r.text +def test_list_exposes_disabled_reason(client: TestClient, db: MagicMock) -> None: + """(#2610) Оператор должен видеть причину ручного выключения в листинге.""" + result = MagicMock() + result.mappings.return_value.all.return_value = [ + _proxy_db_row(id=1, enabled=False, disabled_reason=None), + _proxy_db_row(id=2, enabled=False, disabled_reason="забанен Авито"), + ] + db.execute.side_effect = [result, _bans_result()] + + r = client.get("/api/v1/admin/proxies") + assert r.status_code == 200, r.text + rows = {row["id"]: row for row in r.json()} + assert rows[1]["disabled_reason"] is None # авто-выключен + assert rows[2]["disabled_reason"] == "забанен Авито" # выключен вручную + + +def test_list_exposes_active_source_bans(client: TestClient, db: MagicMock) -> None: + """(#2600 п.2) Узел enabled=true, но забанен Авито — оператор должен видеть, почему + он не выдаётся конкретному источнику; для остальных источников узел в строю.""" + result = MagicMock() + result.mappings.return_value.all.return_value = [ + _proxy_db_row(id=1, enabled=True), + _proxy_db_row(id=2, enabled=True), + ] + db.execute.side_effect = [ + result, + _bans_result( + [ + { + "proxy_id": 1, + "source": "avito", + "banned_until": datetime(2026, 8, 5, 12, tzinfo=UTC), + "ban_count": 2, + } + ] + ), + ] + + r = client.get("/api/v1/admin/proxies") + assert r.status_code == 200, r.text + rows = {row["id"]: row for row in r.json()} + assert rows[1]["source_bans"] == [ + {"source": "avito", "banned_until": "2026-08-05T12:00:00+00:00", "ban_count": 2} + ] + assert rows[2]["source_bans"] == [] # чистый узел — пустой список, а не отсутствие поля + + def test_list_passes_filters(client: TestClient, db: MagicMock) -> None: result = MagicMock() result.mappings.return_value.all.return_value = [] @@ -182,7 +256,7 @@ def test_list_passes_filters(client: TestClient, db: MagicMock) -> None: def test_patch_disable(client: TestClient, db: MagicMock) -> None: result = MagicMock() result.mappings.return_value.fetchone.return_value = _proxy_db_row(enabled=False) - db.execute.return_value = result + db.execute.side_effect = [result, _bans_result()] r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False}) assert r.status_code == 200, r.text @@ -197,3 +271,106 @@ def test_patch_not_found_404(client: TestClient, db: MagicMock) -> None: r = client.patch("/api/v1/admin/proxies/999", json={"enabled": True}) assert r.status_code == 404 + + +# ── PATCH /proxies/{id} — disabled_reason (#2610) ─────────────────────────── + + +def test_patch_disable_sets_disabled_reason_default(client: TestClient, db: MagicMock) -> None: + """(#2610) Ручное выключение без явной причины — ставится дефолтный текст.""" + result = MagicMock() + result.mappings.return_value.fetchone.return_value = _proxy_db_row( + enabled=False, disabled_reason="manually disabled via admin API" + ) + db.execute.side_effect = [result, _bans_result()] + + r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False}) + assert r.status_code == 200, r.text + assert r.json()["disabled_reason"] == "manually disabled via admin API" + # дефолтная причина реально передана в SQL как fallback-параметр (первый execute — + # сам UPDATE; второй, #2600 п.2, дочитывает активные баны по источникам) + params = db.execute.call_args_list[0].args[1] + assert params["default_reason"] + assert params["reason"] is None + + +def test_patch_disable_sets_disabled_reason_custom(client: TestClient, db: MagicMock) -> None: + """(#2610) Оператор передаёт свою причину — она уходит в SQL-параметры.""" + result = MagicMock() + result.mappings.return_value.fetchone.return_value = _proxy_db_row( + enabled=False, disabled_reason="забанен Авито" + ) + db.execute.side_effect = [result, _bans_result()] + + r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False, "reason": "забанен Авито"}) + assert r.status_code == 200, r.text + assert r.json()["disabled_reason"] == "забанен Авито" + params = db.execute.call_args_list[0].args[1] + assert params["reason"] == "забанен Авито" + + +def test_patch_enable_clears_disabled_reason(client: TestClient, db: MagicMock) -> None: + """(#2610, red/green c) Ручное включение сбрасывает disabled_reason → узел снова + авто-восстанавливаем через mark_health.""" + result = MagicMock() + result.mappings.return_value.fetchone.return_value = _proxy_db_row( + enabled=True, disabled_reason=None + ) + db.execute.side_effect = [result, _cleared_bans_result(), _bans_result()] + + r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True}) + assert r.status_code == 200, r.text + assert r.json()["disabled_reason"] is None + params = db.execute.call_args_list[0].args[1] + assert params["enabled"] is True + + +def test_patch_enable_clears_source_bans(client: TestClient, db: MagicMock) -> None: + """(#2600 п.2) Ручное включение = чистый лист: снимаются и per-source баны, иначе у + оператора нет способа отменить ложный бан (детектор капчи, #2642) — узел был бы + enabled=true и всё равно невыдаваемым источнику до 72 часов.""" + result = MagicMock() + result.mappings.return_value.fetchone.return_value = _proxy_db_row( + enabled=True, disabled_reason=None + ) + db.execute.side_effect = [result, _cleared_bans_result([{"source": "avito"}]), _bans_result()] + + r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True}) + assert r.status_code == 200, r.text + delete_sql = str(db.execute.call_args_list[1].args[0]) + assert "DELETE FROM scrape_proxy_source_bans" in delete_sql + assert db.execute.call_args_list[1].args[1]["proxy_id"] == 1 + + +def test_patch_disable_keeps_source_bans(client: TestClient, db: MagicMock) -> None: + """Выключение узла бан-строки НЕ снимает — снятие это «оператор говорит, что узел + в порядке», а выключение утверждает обратное.""" + result = MagicMock() + result.mappings.return_value.fetchone.return_value = _proxy_db_row(enabled=False) + db.execute.side_effect = [result, _bans_result()] + + r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False}) + assert r.status_code == 200, r.text + assert not any( + "DELETE FROM scrape_proxy_source_bans" in str(c.args[0]) for c in db.execute.call_args_list + ) + + +# ── POST /proxies/bulk — не глушит ручной disable (#2610) ────────────────── + + +def test_bulk_upsert_sql_preserves_manual_disable_on_conflict( + client: TestClient, db: MagicMock +) -> None: + """Bulk-upsert (например повторный прогон загрузчика) не должен молча вернуть в + строй узел, выключенный оператором вручную — ON CONFLICT SET enabled должен + проверять disabled_reason существующей строки, а не ставить true безусловно.""" + db.execute.side_effect = [_scalar_result(False)] + r = client.post( + "/api/v1/admin/proxies/bulk", + json={"proxies": [{"url": "http://u:p@host1:8080", "provider_affinity": "avito"}]}, + ) + assert r.status_code == 200, r.text + sql_text = str(db.execute.call_args.args[0]) + assert "disabled_reason IS NULL" in sql_text + assert "scrape_proxies.enabled" in sql_text # fallback — не трогает при ручном disable diff --git a/tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py b/tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py new file mode 100644 index 00000000..aff66ae7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py @@ -0,0 +1,104 @@ +"""#2616: POST /api/v1/admin/scrape (source=avito) прокидывает proxy_provider в AvitoScraper. + +Root cause (issue #2613/#2616): `AvitoScraper(config, delay_provider=get_scraper_delay)` в +`scrape_around` не передавал `proxy_provider=proxy_provider`, хотя переменная уже вычислена +в scope и передаётся в CianScraper/YandexRealtyScraper на соседних строках. Браузерный путь +Avito (SCRAPER_FETCH_MODE=browser) в результате всегда шёл через мёртвый `BROWSER_PROXY_AVITO` +env-прокси вместо пула. + +Offline: db/AvitoScraper/save_listings мокаются, NO live network/DB. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def client() -> TestClient: + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + + def fake_db() -> Any: + yield MagicMock() + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +class _RecordingScraper: + """Recording async-context-manager: пишет init-kwargs в общий список, fetch_around + возвращает пустой список лотов (save_listings ниже мокнут, до БД не доходим).""" + + calls: ClassVar[list[dict[str, Any]]] = [] + + def __init__(self, config: Any, **kwargs: Any) -> None: + type(self).calls.append({"config": config, **kwargs}) + + async def __aenter__(self) -> _RecordingScraper: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def fetch_around(self, *_args: Any, **_kwargs: Any) -> list[Any]: + return [] + + +def test_scrape_avito_passes_proxy_provider(client: TestClient) -> None: + """source=['avito'] → AvitoScraper(config, ..., proxy_provider=). + + Falsification: если admin.py перестанет передавать proxy_provider= в AvitoScraper(...), + calls[-1] не будет содержать ключ 'proxy_provider' со значением sentinel (либо будет + None) — assert падает на VALUE (или KeyError на .get None-fallback), не на TypeError + (recorder принимает **kwargs, сигнатуру не проверяет). + """ + sentinel = object() + _RecordingScraper.calls = [] + + with ( + patch("app.api.v1.admin.AvitoScraper", _RecordingScraper), + patch("app.api.v1.admin._kit_proxy_provider", return_value=sentinel), + patch("app.api.v1.admin.save_listings", return_value=(0, 0)), + ): + r = client.post( + "/api/v1/admin/scrape", + json={"lat": 56.8332, "lon": 60.5944, "radius_m": 1000, "sources": ["avito"]}, + ) + + assert r.status_code == 200 + assert len(_RecordingScraper.calls) == 1 + assert _RecordingScraper.calls[0].get("proxy_provider") is sentinel + + +def test_scrape_avito_no_pool_default_none(client: TestClient) -> None: + """_kit_proxy_provider() → None (pool off, ship-dark default) → AvitoScraper получает + + proxy_provider=None — поведение прежнее. + """ + _RecordingScraper.calls = [] + + with ( + patch("app.api.v1.admin.AvitoScraper", _RecordingScraper), + patch("app.api.v1.admin._kit_proxy_provider", return_value=None), + patch("app.api.v1.admin.save_listings", return_value=(0, 0)), + ): + r = client.post( + "/api/v1/admin/scrape", + json={"lat": 56.8332, "lon": 60.5944, "radius_m": 1000, "sources": ["avito"]}, + ) + + assert r.status_code == 200 + assert len(_RecordingScraper.calls) == 1 + assert _RecordingScraper.calls[0].get("proxy_provider") is None diff --git a/tradein-mvp/backend/tests/test_admin_stale_counters.py b/tradein-mvp/backend/tests/test_admin_stale_counters.py new file mode 100644 index 00000000..4fd4cbf1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_stale_counters.py @@ -0,0 +1,205 @@ +"""#2660: админ-счётчики честно разделяют «активно» и «из них не виделись N дней». + +Почему это баг, а не косметика: `is_active` снимается только деактиватором протухших, +а он покрывает не все источники — прод (2026-08-05) показывал cian 18 530 «активных» +при 12 683 не виденных 14+ дней и 10 212 не виденных 30+. Именно поэтому #2574 месяц +читалась как «всё собирается». Счётчик не прячем (иначе потеряем видимость проблемы) — +отдаём вторым числом рядом. + +Покрыто (db мокается, NO live network/DB): + - GET /api/v1/admin/scraper/data-quality — stale_count per source + stale_days; + - GET /api/v1/trade-in/cache-stats — listings_active_stale + listings_stale_days; + - порог свежести = LISTINGS_FRESH_DAYS эстиматора (не второе определение); + - оба запроса считают протухшее по last_seen_at И только среди is_active. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +class _FakeResult: + def __init__( + self, + *, + rows: list[dict] | None = None, + one: dict | None = None, + scalar_value: Any = None, + ) -> None: + self._rows = rows or [] + self._one = one + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + def mappings(self) -> Any: + outer = self + + class _Mappings: + def all(self) -> list[dict]: + return outer._rows + + def one(self) -> dict: + assert outer._one is not None + return outer._one + + def fetchone(self) -> dict | None: + return outer._one + + return _Mappings() + + +class _FakeDB: + """Session stand-in: execute() отдаёт очередь заготовленных результатов.""" + + def __init__(self, results: list[_FakeResult]) -> None: + self._results = list(results) + self.executed: list[tuple[Any, Any]] = [] + + def execute(self, clause: Any, params: Any = None) -> _FakeResult: + self.executed.append((clause, params)) + return self._results.pop(0) + + +# Одна строка coverage-выборки: source + счётчики + fill-агрегаты по всем полям. +def _dq_row(source: str, active: int, stale: int) -> dict[str, Any]: + from app.api.v1.admin import _DQ_LISTING_FIELDS + + row: dict[str, Any] = {"source": source, "active_count": active, "stale_count": stale} + for name, _expr in _DQ_LISTING_FIELDS: + row[f"f_{name}"] = active # 100% fill — не предмет этого теста + return row + + +_HOUSES_ROW = {"total": 10, "validated_cnt": 5, "rating_cnt": 5, "house_type_cnt": 5} + + +def _make_client(db: _FakeDB, router_module: str, prefix: str) -> TestClient: + import importlib + + from app.core.db import get_db + + module = importlib.import_module(router_module) + app = FastAPI() + app.include_router(module.router, prefix=prefix) + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + +# ── GET /scraper/data-quality ──────────────────────────────────────────────── + + +@pytest.fixture +def dq_db() -> _FakeDB: + return _FakeDB( + [ + _FakeResult( + rows=[ + # Прод-пропорции 2026-08-05: cian почти весь протухший, avito живой. + _dq_row("cian", 18_530, 12_683), + _dq_row("avito", 4_975, 0), + ] + ), + _FakeResult(one=_HOUSES_ROW), + _FakeResult(scalar_value=0), + ] + ) + + +def test_data_quality_reports_stale_next_to_active(dq_db: _FakeDB) -> None: + """«Активно» остаётся как было, рядом появляется «из них не виделись N дней».""" + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + r = client.get("/api/v1/admin/scraper/data-quality") + assert r.status_code == 200 + body = r.json() + by_source = {s["source"]: s for s in body["sources"]} + assert by_source["cian"]["active_count"] == 18_530 + assert by_source["cian"]["stale_count"] == 12_683 + # Живой источник — протухших нет, счётчик не «шумит» на здоровых данных. + assert by_source["avito"]["active_count"] == 4_975 + assert by_source["avito"]["stale_count"] == 0 + + +def test_data_quality_exposes_the_threshold_used(dq_db: _FakeDB) -> None: + """Порог отдаётся в ответе — UI подписывает число, а не хардкодит своё N.""" + from app.services.estimator import LISTINGS_FRESH_DAYS + + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + body = client.get("/api/v1/admin/scraper/data-quality").json() + assert body["stale_days"] == LISTINGS_FRESH_DAYS + + +def test_data_quality_counts_stale_by_last_seen_within_active_only(dq_db: _FakeDB) -> None: + """Протухшее считается по last_seen_at (буквально «не виделись») и ТОЛЬКО среди + активных — иначе счётчик смешает деактивированные строки и перестанет быть + сопоставимым с active_count.""" + from app.services.estimator import LISTINGS_FRESH_DAYS + + client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") + client.get("/api/v1/admin/scraper/data-quality") + clause, params = dq_db.executed[0] + sql = str(clause) + assert "COUNT(*) FILTER (" in sql + assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql + assert "WHERE is_active = true" in sql + assert params == {"fresh_days": LISTINGS_FRESH_DAYS} + + +# ── GET /trade-in/cache-stats ──────────────────────────────────────────────── + + +def _cache_stats_row() -> dict[str, Any]: + return { + "geocode_cache": 1, + "geocode_cache_fresh": 1, + "listings_active": 37_900, + "listings_active_stale": 20_935, + "listings_last_scraped": None, + "deals": 0, + "cad_buildings": 0, + "house_metadata": 0, + "estimates_total": 0, + "avg_median_price": None, + "repeat_address_pct": None, + } + + +def test_cache_stats_splits_active_and_stale() -> None: + from app.services.estimator import LISTINGS_FRESH_DAYS + + db = _FakeDB([_FakeResult(one=_cache_stats_row())]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + body = client.get("/api/v1/trade-in/cache-stats").json() + assert body["listings_active"] == 37_900 + assert body["listings_active_stale"] == 20_935 + assert body["listings_stale_days"] == LISTINGS_FRESH_DAYS + + +def test_cache_stats_counts_stale_by_last_seen_within_active_only() -> None: + from app.services.estimator import LISTINGS_FRESH_DAYS + + db = _FakeDB([_FakeResult(one=_cache_stats_row())]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + client.get("/api/v1/trade-in/cache-stats") + clause, params = db.executed[0] + sql = str(clause) + assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql + assert "listings_active_stale" in sql + assert params == {"fresh_days": LISTINGS_FRESH_DAYS} + + +def test_cache_stats_empty_db_returns_empty_dict_not_a_fake_threshold() -> None: + """Нет строки — отдаём {}, а не {listings_stale_days: N} с пустыми счётчиками + (иначе UI отрисует подпись к несуществующему числу).""" + db = _FakeDB([_FakeResult(one=None)]) + client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") + assert client.get("/api/v1/trade-in/cache-stats").json() == {} diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py new file mode 100644 index 00000000..ae084d93 --- /dev/null +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -0,0 +1,379 @@ +"""Сигналы о сбоях действительно становятся событиями GlitchTip (#2674). + +Почему обычной проверки уровня записи мало. В контейнере скрапера GlitchTip поднят +как `LoggingIntegration(level=INFO, event_level=ERROR)` (app/scheduler_main.py) — +значит WARNING остаётся строкой в docker-логе (которая теряется на редеплое) и +событием НЕ становится. Прод-цена этого: монитор устаревания СберИндекса отработал +24 раза, 9 из них со staleness-вердиктом — событий ноль; куки Домклика протухли +2026-08-03 — событий ноль. + +Поэтому здесь тесты проверяют ФАКТ СОБЫТИЯ, а не levelno: `glitchtip_events()` +поднимает настоящий sentry-клиент с той же интеграцией и тем же event_level, что в +проде, но с транспортом-списком. Если правку откатить (ERROR → WARNING), список +останется пустым и тест покраснеет. + +Оговорка, которую тесты проверить не могут: у GlitchTip-проекта сейчас нет ни правил, +ни получателей (#2673) — события будут видны в интерфейсе, но никому не отправятся. + +Без сети, без БД. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, date, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import domclick_session as domclick_session_svc +from app.services import rosreestr_poll, sber_index +from app.tasks import deals_freshness_monitor as deals_mon +from app.tasks import domclick_detail_backfill as dc_backfill +from app.tasks import sber_freshness_monitor as sber_mon + +# ── харнесс: настоящий клиент GlitchTip с прод-настройками, транспорт — список ── + + +@contextmanager +def glitchtip_events() -> Iterator[list[dict[str, Any]]]: + """Собрать события так, как их увидел бы GlitchTip из контейнера скрапера. + + Интеграция и event_level — копия app/scheduler_main.py. Клиент ставится только + на время блока (isolation_scope), глобальное состояние не трогаем. + """ + events: list[dict[str, Any]] = [] + + def _collect(event: dict[str, Any], _hint: dict[str, Any]) -> None: + """before_send: событие уже собрано — записываем и НЕ отправляем (None).""" + events.append(event) + return None + + client = sentry_sdk.Client( + dsn="https://public@localhost/1", + before_send=_collect, + default_integrations=False, + integrations=[LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)], + ) + with sentry_sdk.isolation_scope() as scope: + scope.set_client(client) + try: + yield events + finally: + # Иначе на каждый тест остаётся фоновый поток транспорта. + client.close() + + +def event_texts(events: list[dict[str, Any]]) -> list[str]: + """Тексты событий — и логовых (logentry), и явных capture_message (message).""" + texts: list[str] = [] + for event in events: + entry = event.get("logentry") + if isinstance(entry, dict): + texts.append(str(entry.get("formatted") or entry.get("message") or "")) + elif isinstance(event.get("message"), str): + texts.append(event["message"]) + return texts + + +def test_harness_itself_drops_warnings() -> None: + """Мета-проверка харнесса: WARNING не событие, ERROR — событие. + + Без этого зелёные тесты ниже ничего не доказывали бы (пустой список мог бы быть + следствием сломанного харнесса, а не сломанного алерта). + """ + log = logging.getLogger("test_alerts_become_events.meta") + with glitchtip_events() as events: + log.warning("тихо") + log.error("громко") + assert event_texts(events) == ["громко"] + + +# ── 1. СберИндекс: монитор устаревания ──────────────────────────────────────── + + +class _FakeMonitorDB: + """Session-мок мониторов свежести: один SELECT max(...).""" + + def __init__(self, latest: date | None) -> None: + self._latest = latest + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + result.first.return_value = MagicMock(latest=self._latest) + return result + + def rollback(self) -> None: + pass + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None: + monkeypatch.setattr(module.runs_mod, "update_heartbeat", lambda *a, **k: None) + monkeypatch.setattr(module.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None) + + +def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) + with glitchtip_events() as events: + out = sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=1, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert out["alert"] == 1 + assert any( + "sber freshness" in t for t in event_texts(events) + ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает" + + +def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Свежие данные — ни одного события (иначе алерт-усталость).""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) + with glitchtip_events() as events: + out = sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=2, + params={}, + now=datetime(2026, 6, 20, tzinfo=UTC), + ) + assert out["alert"] == 0 + assert event_texts(events) == [] + + +def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Бенчмарк пуст — монитор не может работать вовсе; mark_failed виден только стрику.""" + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(None) + with glitchtip_events() as events: + sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=3, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert any("sber_price_index пуст" in t for t in event_texts(events)) + + +def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же класс у соседнего монитора сделок — найдено «шире» по #2674.""" + _patch_runs(monkeypatch, deals_mon) + db = _FakeMonitorDB(None) + with glitchtip_events() as events: + deals_mon.check_deals_freshness( + db, # type: ignore[arg-type] + run_id=4, + params={}, + now=datetime(2026, 8, 6, tzinfo=UTC), + ) + assert any("deals пуста" in t for t in event_texts(events)) + + +# ── 2. СберИндекс: 404 датасета (почему бенчмарк перестаёт обновляться) ──────── + + +async def test_sber_index_404_becomes_event() -> None: + """404 = переименованный slug: сбой ПЕРМАНЕНТНЫЙ, а был тише соседних 5xx-веток.""" + request = httpx.Request("GET", "https://sberindex.ru/api/sowa") + + def _raise_404(*args: Any, **kwargs: Any) -> Any: + raise httpx.HTTPStatusError( + "404", request=request, response=httpx.Response(404, request=request) + ) + + with ( + patch.object(sber_index, "fetch_sber_index", _raise_404), + glitchtip_events() as events, + ): + counters = await sber_index.pull_sber_indices( + MagicMock(), + cities={"66": "Свердловская область"}, + dashboards=[sber_index.SBER_DASHBOARDS[0]], + ) + + assert counters["errors"] == 1 + assert any("404 for dashboard" in t for t in event_texts(events)) + + +# ── 3. Куки Домклика: по факту и ЗАРАНЕЕ (образец — #2658 для Циана) ────────── + + +class _FakeDomclickDB: + """Session-мок: строка кук для session_expires_at + пустой снапшот листингов.""" + + def __init__(self, expires_at: datetime | None) -> None: + self._expires_at = expires_at + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + result = MagicMock() + if "domclick_session_cookies" in str(stmt): + result.first.return_value = None if self._expires_at is None else (self._expires_at,) + else: + # snapshot листингов — пусто, прогон завершится до BrowserFetcher + result.mappings.return_value.all.return_value = [] + return result + + +async def _run_domclick( + monkeypatch: pytest.MonkeyPatch, + *, + cookies: dict[str, str] | None, + expires_at: datetime | None, +) -> list[str]: + _patch_runs(monkeypatch, dc_backfill) + monkeypatch.setattr(domclick_session_svc, "load_session", lambda _db: cookies) + db = _FakeDomclickDB(expires_at) + with glitchtip_events() as events: + await dc_backfill.run_domclick_detail_backfill( + db, # type: ignore[arg-type] + run_id=10, + params={}, + ) + return event_texts(events) + + +async def test_domclick_expired_cookies_become_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-03: протухли, единственным следом был WARNING в docker-логе.""" + expired = datetime.now(tz=UTC) - timedelta(days=3) + texts = await _run_domclick(monkeypatch, cookies=None, expires_at=expired) + assert any("протухли" in t for t in texts), "протухшие куки не стали событием" + # Дата в тексте — чтобы оператор сразу видел, чинить сейчас или это давняя дыра. + assert any(expired.strftime("%Y-%m-%d") in t for t in texts) + + +async def test_domclick_missing_cookies_reason_differs(monkeypatch: pytest.MonkeyPatch) -> None: + """«Кук нет вовсе» и «протухли» лечатся одинаково, но диагностируются по-разному.""" + texts = await _run_domclick(monkeypatch, cookies=None, expires_at=None) + assert any("кук DomClick нет в БД" in t for t in texts) + + +async def test_domclick_warns_before_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + """Предупреждение ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше порога. + + Сигнал по факту протухания приходит, когда обогащение уже встало, а обновление + кук — ручная операция. По образцу #2658 (Циан). + """ + soon = datetime.now(tz=UTC) + timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS - 1) + texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=soon) + assert any("протухнут" in t for t in texts), "не предупредили заранее" + + +async def test_domclick_fresh_cookies_are_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Свежие куки — ни одного события.""" + far = datetime.now(tz=UTC) + timedelta(days=25) + texts = await _run_domclick(monkeypatch, cookies={"CAS_ID": "x"}, expires_at=far) + assert texts == [] + + +def test_domclick_expiry_query_asks_for_the_used_row() -> None: + """valid_only=True — срок ИМЕННО той записи, которую взял бы load_session. + + При нескольких аккаунтах свежайшая-любая может быть чужой протухшей строкой. + """ + db = MagicMock() + db.execute.return_value.first.return_value = None + domclick_session_svc.session_expires_at(db, valid_only=True) + assert db.execute.call_args.args[1] == {"valid_only": True} + + +# ── 4. Поллер Росреестра: сбой каталога vs выход квартала ───────────────────── + + +def _client_returning(status_code: int) -> MagicMock: + client = MagicMock() + client.get = AsyncMock(return_value=httpx.Response(status_code, text="")) + return client + + +async def test_rosreestr_broken_index_becomes_event() -> None: + """Каталог не отдаёт 200 — поллер слеп. Портал уже один раз переезжал.""" + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available( + _client_returning(503), 2026, 3 + ) + assert available is False + assert any("unexpected HTTP 503" in t for t in event_texts(events)) + + +async def test_rosreestr_stub_instead_of_zip_becomes_event() -> None: + """Файл есть в листинге, но HEAD отдал заглушку — тот сбой, из-за которого уже врали. + + Ровно поведение старой Bitrix-заглушки: HTTP 200 + text/html вместо архива. + """ + index_html = 'q' + folder_html = 'f' + client = MagicMock() + client.get = AsyncMock( + side_effect=[ + httpx.Response(200, text=index_html), + httpx.Response(200, text=folder_html), + ] + ) + client.head = AsyncMock( + return_value=httpx.Response( + 200, text="stub", headers={"content-type": "text/html", "content-length": "512"} + ) + ) + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available(client, 2026, 3) + + assert available is False + assert any("soft-404 guard" in t for t in event_texts(events)) + + +async def test_rosreestr_quarter_not_published_is_silent() -> None: + """Каталог жив, папки квартала ещё нет — самый частый прогон, событий быть не должно.""" + with glitchtip_events() as events: + available = await rosreestr_poll.check_new_quarter_available( + _client_returning(200), 2026, 3 + ) + assert available is False + assert event_texts(events) == [] + + +async def test_rosreestr_timeout_stays_out_of_events() -> None: + """Осознанно НЕ событие: транспортный блип раз в 28 дней сам пройдёт. + + «Квартал так и не приехал» ловит deals_freshness_monitor по max(deal_date). + """ + client = MagicMock() + client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + with glitchtip_events() as events: + await rosreestr_poll.check_new_quarter_available(client, 2026, 3) + assert event_texts(events) == [] + + +async def test_rosreestr_new_quarter_becomes_event() -> None: + """Хорошая новость — тоже событие: она требует ручного импорта оператором. + + Уровень info, а не error: событие в GlitchTip есть, а error-rate и стрик-алерты + не начинают врать про «сбой». INFO-строка в логе живёт до ближайшего редеплоя. + """ + with ( + patch.object(rosreestr_poll, "latest_loaded_quarter", MagicMock(return_value=(2026, 2))), + patch.object(rosreestr_poll, "check_new_quarter_available", AsyncMock(return_value=True)), + glitchtip_events() as events, + ): + out = await rosreestr_poll.poll_rosreestr_new_quarter(MagicMock()) + + assert out == { + "available": True, + "year": 2026, + "quarter": 3, + "latest_loaded_year": 2026, + "latest_loaded_quarter": 2, + } + assert any("доступен новый квартал Q3 2026" in t for t in event_texts(events)) diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py index 9f4e8d21..671d8f58 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -116,17 +116,59 @@ def test_rederivation_cte_blocks_match_080() -> None: def test_rederivation_scopes_sold_side_to_asking_city() -> None: """#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ). - Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа - sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба - deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем. + Миграция 177 залила ДКП по всей обл.66, а asking (listings) исторически — только ЕКБ. + Без скоупа sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. + Оба deal-CTE (per-rooms + global) несут предикат unconditionally (deals.city не имеет + массовых NULL как listings.city — #2598 их не касается). """ assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%" - # Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения. + # Оба deal-CTE (deal_side + deal_global) скоупятся unconditional-предикатом — + # ровно 2 вхождения формы БЕЗ city IS NULL (ask-сторона использует другую форму, + # см. test_ask_side_and_ask_global_scoped_to_asking_city). assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2 - # ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city). + + +def test_ask_side_and_ask_global_scoped_to_asking_city() -> None: + """#2583 H2: ask-сторона (ask_side + ask_global) ТЕПЕРЬ ТОЖЕ скоупится на asking_city. + + Oblast-развёртки заработали 12 июля — областные объявления (дешевле ЕКБ) попали в + знаменатель ask_median БЕЗ городского скоупа, а sold-сторона осталась скоуплена на + ЕКБ (см. предыдущий тест) → асимметрия занижала ask_median и завышала ratio на + 2.5-5.3% по бакетам комнат 1-4 (замер на проде, аудит #2583 H2). Falsifiable: этот + assert FALSE на непропатченном коде (ask_side/ask_global без city-предиката вообще) + и TRUE после того как предикат `(city IS NULL OR city ILIKE :asking_city)` добавлен — + проверено `git stash` на строках реализации. + """ _a = _REDERIVE_SQL.index("ask_side AS") _b = _REDERIVE_SQL.index("per_bucket AS") - assert "asking_city" not in _REDERIVE_SQL[_a:_b] + ask_side_block = _REDERIVE_SQL[_a:_b] + assert "AND (city IS NULL OR city ILIKE :asking_city)" in ask_side_block + + _c = _REDERIVE_SQL.index("ask_global AS") + _d = _REDERIVE_SQL.index("global_row AS") + ask_global_block = _REDERIVE_SQL[_c:_d] + assert "AND (city IS NULL OR city ILIKE :asking_city)" in ask_global_block + + +def test_ask_side_keeps_city_is_null_rows_not_naive_filter() -> None: + """Guard against the naive (wrong) fix — a plain symmetric `city ILIKE :asking_city`. + + listings.city заполнена пока только у Авито (#2598/#2606) — Циан/Домклик/Яндекс + строки несут city IS NULL. На проде (2026-08, аудит #2583 H2) это ~8200 из ~11500 + строк, проходящих остальные WHERE-предикаты (~71%). Наивный симметричный + `city ILIKE :asking_city` (как у deal_side) молча выбросил бы все city IS NULL + строки, схлопнув ask_median c ~11500 до ~2100 ЕКБ-only объявлений — именно та + over-correction, от которой предостерегает #2583 H2. + """ + cte_pairs = (("ask_side AS", "per_bucket AS"), ("ask_global AS", "global_row AS")) + for cte_name, next_cte in cte_pairs: + start = _REDERIVE_SQL.index(cte_name) + end = _REDERIVE_SQL.index(next_cte) + block = _REDERIVE_SQL[start:end] + assert "city IS NULL" in block, f"{cte_name}: missing IS NULL tolerance" + # The naive fix (deal_side-style, no NULL tolerance) must NOT appear standalone. + naive = re.search(r"AND\s+city\s+ILIKE\s+:asking_city(?!\))", block) + assert naive is None, f"{cte_name}: found naive filter without IS NULL tolerance" def _strip_sql(s: str) -> str: @@ -152,6 +194,20 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: #1186: the refresh now adds the novostroyki guard predicate to each ask_* CTE; it is normalised away here so the 080 seed (no guard) still matches. + + #2583 H2: the refresh now also adds the NULL-tolerant city-scope predicate to each + ask_* CTE (symmetric to the #C2 SOLD-side guard) — normalised away the same way. + + #2620: the refresh now buckets ask_side by AREA (_AREA_ROOMS_BUCKET_SQL) instead of + listings.rooms — the 080 seed still uses the rooms-based LEAST/GREATEST formula for + ask_side (pre-#2620, the bug this fixes). Both bucket formulas normalise to the same + placeholder token so this test keeps proving everything ELSE unchanged (CTE shape, + threshold, window, deal_side bucketing) — the #2620 divergence itself is asserted by + test_ask_side_buckets_by_area_not_rooms below. + + #2620 hardening: the refresh also adds `AND area_m2 IS NOT NULL` to ask_side/ask_global + (NULL area_m2 would fall into the CASE ELSE branch = bucket 4 — a latent trap). Absent + in the 080 seed; dropped here the same way as the other guards above. """ seed_sql = _MIGRATION_080.read_text("utf-8") # Extract the WITH … (up to the ON CONFLICT) from the seed. @@ -176,15 +232,86 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: ) def _drop_city_guard(s: str) -> str: - """Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed).""" - return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s) + """Remove the #C2 SOLD-side + #2583 H2 ASK-side city-scope predicates. + + Both are absent in the 080 seed: #C2 added the unconditional SOLD-side guard + (deal_side/deal_global), #2583 H2 later added the NULL-tolerant ASK-side guard + (ask_side/ask_global). + """ + s = re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s) + s = re.sub(r"AND\s*\(\s*city\s+IS\s+NULL\s+OR\s+city\s+ILIKE\s+:asking_city\s*\)", "", s) + return s + + def _normalise_bucket_expr(s: str) -> str: + """Collapse the rooms-based and area-based bucket formulas to one placeholder. + + #2620: ask_side buckets by area now (_AREA_ROOMS_BUCKET_SQL), not rooms. Both + forms appear an equal number of times (2x each: SELECT expr + GROUP BY) once + deal_side's untouched rooms-formula occurrences are also normalised, so this + keeps the containment check valid for the parts of the derivation #2620 did NOT + touch (deal_side stays rooms-bucketed — it has no other choice, deals.rooms IS + the synthetic area bucket already). + """ + s = re.sub(r"LEAST\(GREATEST\(rooms,\s*0\),\s*4\)", "BUCKET_PLACEHOLDER", s) + s = re.sub( + r"CASE WHEN area_m2 < 30 THEN 0 WHEN area_m2 < 44 THEN 1 " + r"WHEN area_m2 < 62 THEN 2 WHEN area_m2 < 85 THEN 3 ELSE 4 END", + "BUCKET_PLACEHOLDER", + s, + ) + return s + + def _drop_area_not_null_guard(s: str) -> str: + """Remove the #2620 hardening guard (absent in the 080 seed). + + NULL area_m2 falls into the CASE ELSE branch (bucket 4) of the area formula — a + latent trap. ask_side/ask_global added `AND area_m2 IS NOT NULL` explicitly. + """ + return re.sub(r"AND\s+area_m2\s+IS\s+NOT\s+NULL", "", s) def _norm(s: str) -> str: - return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s)))) + return _strip_sql( + _normalise_ppm2( + _normalise_bucket_expr( + _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + ) + ) + ) assert _norm(seed_derivation) in _norm(_REDERIVE_SQL) +def test_ask_side_buckets_by_area_not_rooms() -> None: + """#2620: ask_side buckets listings by AREA (same CASE as deals), not listings.rooms. + + Root cause of the ratio>1 bug in bucket "4+": deals.rooms is synthetic (derived from + area_m2 at import time, deploy/import-rosreestr.sh — Rosreestr doesn't report room + counts), while listings.rooms is a REAL room count. Comparing a synthetic area-bucket + to a real-rooms-bucket mixed two different classifications — measured on prod + (2026-08, #2620): 23-55% of listings migrate to a different bucket depending on which + classification is used, not just in the "4+" bucket (which was ALSO truncated: deals + caps rooms at 4 via ELSE 4, listings.rooms does not). + + Falsifiable: this assert is FALSE on the pre-#2620 code (ask_side bucketed by + LEAST(GREATEST(rooms, 0), 4), same as deal_side) and TRUE once ask_side switches to + _AREA_ROOMS_BUCKET_SQL. + """ + assert ratio_mod._AREA_ROOMS_BUCKET_SQL.startswith("CASE WHEN area_m2 < 30 THEN 0") + assert ratio_mod._AREA_ROOMS_BUCKET_SQL in _REDERIVE_SQL + + _a = _REDERIVE_SQL.index("ask_side AS") + _b = _REDERIVE_SQL.index("per_bucket AS") + ask_side_block = _REDERIVE_SQL[_a:_b] + assert ratio_mod._AREA_ROOMS_BUCKET_SQL in ask_side_block + assert "LEAST(GREATEST(rooms" not in ask_side_block + + # deal_side is UNCHANGED — deals.rooms is already the synthetic area bucket, there is + # no separate "real rooms" column to prefer instead (see docstring above). + _c = _REDERIVE_SQL.index("deal_side AS") + deal_side_block = _REDERIVE_SQL[_c:_a] + assert "LEAST(GREATEST(rooms, 0), 4)" in deal_side_block + + # ── Counters query ──────────────────────────────────────────────────────────── @@ -374,3 +501,42 @@ def test_migration_098_band_matches_settings_default() -> None: assert ( Settings().asking_ratio_ppm2_max == 1_200_000 ), "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match" + + +# ── area_bucket() Python twin matches _AREA_ROOMS_BUCKET_SQL (#2620) ───────── + + +def test_area_bucket_matches_sql_boundaries() -> None: + """area_bucket() has IDENTICAL boundaries to _AREA_ROOMS_BUCKET_SQL (#2620). + + Two representations of one truth (see module comment: shell/import-rosreestr.sh → SQL + _AREA_ROOMS_BUCKET_SQL → Python area_bucket()). If SQL (computes the ratio in ask_side) + and Python (applies it in estimator.py) drift apart, the #2620 mismatch is silently + reintroduced. Boundaries are parsed straight out of the SQL string, not hardcoded + independently, so this fails the moment either one is edited without the other. + """ + sql_bounds = [int(n) for n in re.findall(r"area_m2 < (\d+)", ratio_mod._AREA_ROOMS_BUCKET_SQL)] + assert sql_bounds == [30, 44, 62, 85] + + cases = { + 0: 0, + 29: 0, + 29.99: 0, + 30: 1, + 43: 1, + 43.99: 1, + 44: 2, + 61: 2, + 61.99: 2, + 62: 3, + 84: 3, + 84.99: 3, + 85: 4, + 200: 4, + } + for area, expected in cases.items(): + assert ratio_mod.area_bucket(area) == expected, f"area={area} -> expected {expected}" + + for i, bound in enumerate(sql_bounds): + assert ratio_mod.area_bucket(bound - 0.01) == i + assert ratio_mod.area_bucket(bound) == i + 1 diff --git a/tradein-mvp/backend/tests/test_audit_address_mismatch.py b/tradein-mvp/backend/tests/test_audit_address_mismatch.py deleted file mode 100644 index 1c64288b..00000000 --- a/tradein-mvp/backend/tests/test_audit_address_mismatch.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Unit tests for the Phase-1 address-mismatch audit (issue #582). - -Coverage: - - `_first_street_token` / `_street_differs` — normalization-driven diff. - - `_distance_meters` — verified against a MagicMock'd DB that returns a - canned distance, plus a Haversine cross-check on the bind values to - catch lat/lon swaps. - - `reverse_via_api` via httpx MockTransport with the fixture file. - - `main()` resumability — call twice with the same batch, second call - inserts 0 (uses MagicMock DB session). - -Why no real Postgres in unit tests: -The repo doesn't bundle pytest-postgresql / testcontainers and the existing -tests all use `MagicMock` for the DB. We follow that convention here. The -distance and SQL-level resumability are validated by: - - The Haversine cross-check (pure-Python expected ≈ PostGIS result for - same coords, see `test_distance_calc_matches_haversine`). - - Calling `main()` twice in `test_audit_script_resumable` — first run - inserts N rows, second run sees the same set of house_ids in the - "already processed" query and processes 0. -""" - -from __future__ import annotations - -import json -import math -import os -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -# Settings requires DATABASE_URL at init time — set dummy DSN before any -# `app.*` import (same pattern as test_cian_valuation.py). -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -import httpx -import pytest - -from scripts._yandex_reverse import ( - YandexBlockedError, - YandexReverseResult, - _parse_api_payload, - reverse_via_api, -) -from scripts.audit_address_mismatch import ( - SampleRow, - _distance_meters, - _first_street_token, - _resolve_mode, - _run_api_mode, - _street_differs, - main, -) - -_FIXTURES = Path(__file__).parent / "fixtures" - - -# --------------------------------------------------------------------------- -# _first_street_token / _street_differs -# --------------------------------------------------------------------------- - - -def test_normalize_address_street_token_basic(): - """First identifying token of a normalized address — skips street type.""" - assert _first_street_token("ул Малышева 51") == "малышева" - - -def test_normalize_address_street_token_skips_leading_numbers(): - """Numeric tokens are skipped — the street name carries identity.""" - # No type prefix → first non-numeric token is the street name itself. - assert _first_street_token("123 Постовского") == "постовского" - - -def test_normalize_address_street_token_handles_none(): - assert _first_street_token(None) is None - assert _first_street_token("") is None - - -def test_street_differs_true_when_streets_differ(): - assert _street_differs("ул Малышева 51", "ул Ленина 51") is True - - -def test_street_differs_false_when_same_after_normalization(): - # 'ул' expands to 'улица' on both sides → same first token. - assert _street_differs("ул Малышева 51", "улица Малышева, 51") is False - - -def test_street_differs_none_on_empty_side(): - assert _street_differs(None, "ул Малышева 51") is None - assert _street_differs("ул Малышева 51", "") is None - - -# --------------------------------------------------------------------------- -# _distance_meters — MagicMock DB + Haversine cross-check -# --------------------------------------------------------------------------- - - -def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Reference implementation for sanity-checking the PostGIS call.""" - r = 6_371_000.0 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * r * math.asin(math.sqrt(a)) - - -def test_distance_calc_passes_correct_bindings(): - """Test the helper passes lat/lon in correct order to the SQL bind names.""" - db = MagicMock() - # PostGIS would return one row, single column (distance in meters). - db.execute.return_value.first.return_value = (123.45,) - - out = _distance_meters(db, 56.838, 60.586, 56.840, 60.590) - - assert out == 123.45 - # Verify the bind dict — guard against lat/lon swap regressions. - args, _kwargs = db.execute.call_args - bound = args[1] - assert bound == { - "olat": 56.838, - "olon": 60.586, - "slat": 56.840, - "slon": 60.590, - } - - -def test_distance_calc_returns_none_when_postgis_null(): - """ST_Distance can return NULL — caller must propagate None, not 0.""" - db = MagicMock() - db.execute.return_value.first.return_value = (None,) - assert _distance_meters(db, 56.0, 60.0, 56.0, 60.0) is None - - -def test_distance_calc_matches_haversine_within_tolerance(): - """Sanity check: if PostGIS returned 555.7m for a known pair, that's - within ~1% of the Haversine reference (PostGIS uses Vincenty on - geography which is slightly more accurate).""" - expected = _haversine_m(56.838, 60.586, 56.843, 60.591) - # Just assert reference is in a sensible range — proves the test helper - # works; the actual call is mocked. - assert 500 < expected < 700 - - -# --------------------------------------------------------------------------- -# Yandex API: payload parsing + reverse_via_api with MockTransport -# --------------------------------------------------------------------------- - - -def test_yandex_parse_api_fixture(): - """Sanity check: parse the bundled fixture into a YandexReverseResult.""" - data = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8")) - res = _parse_api_payload(data) - assert res.address is not None - assert "Малышева" in res.address - # Fixture Point.pos = "60.586155 56.838004" → lon then lat. - assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6) - assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6) - assert res.raw == data - - -def test_yandex_parse_api_no_match(): - """Empty featureMember → all-None result, raw still preserved.""" - data = {"response": {"GeoObjectCollection": {"featureMember": []}}} - res = _parse_api_payload(data) - assert res.address is None - assert res.snapped_lat is None - assert res.snapped_lon is None - assert res.raw == data - - -async def test_yandex_reverse_api_mock(): - """End-to-end: reverse_via_api hits a MockTransport, returns parsed result.""" - fixture = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8")) - - captured: dict[str, httpx.Request] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["req"] = request - return httpx.Response(200, json=fixture) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - res = await reverse_via_api(56.838004, 60.586155, "DUMMY_KEY", client=client) - - assert res.address is not None and "Малышева" in res.address - # Verify the request shape — lon,lat order + apikey + kind=house. - req = captured["req"] - qs = dict(httpx.QueryParams(req.url.query)) - assert qs["apikey"] == "DUMMY_KEY" - assert qs["geocode"] == "60.586155,56.838004" - assert qs["format"] == "json" - assert qs["kind"] == "house" - - -async def test_yandex_blocked_error_raised_on_captcha(): - """`reverse_via_playwright` must raise YandexBlockedError on captcha. - - We mock the page object so we don't need an actual browser. - """ - from scripts._yandex_reverse import reverse_via_playwright - - page = MagicMock() - page.goto = AsyncMock() - page.wait_for_load_state = AsyncMock() - page.query_selector = AsyncMock( - side_effect=lambda sel: MagicMock() if sel == ".CheckboxCaptcha" else None - ) - page.evaluate = AsyncMock(return_value=[None, None]) - - with pytest.raises(YandexBlockedError): - await reverse_via_playwright(56.838, 60.586, page) - - -# --------------------------------------------------------------------------- -# Mode resolver -# --------------------------------------------------------------------------- - - -def test_resolve_mode_auto_with_key(): - assert _resolve_mode("auto", "abc") == "api" - - -def test_resolve_mode_auto_without_key(): - assert _resolve_mode("auto", None) == "playwright" - assert _resolve_mode("auto", "") == "playwright" - - -def test_resolve_mode_explicit_passes_through(): - assert _resolve_mode("api", None) == "api" - assert _resolve_mode("playwright", "abc") == "playwright" - - -# --------------------------------------------------------------------------- -# Resumability — main() twice with same batch -# --------------------------------------------------------------------------- - - -def _make_db_mock(initial_sample: list[dict], processed_ids: set[int]): - """Build a MagicMock SQLAlchemy session that: - - returns `initial_sample` for the sampling SQL (text() with limit_per_district) - - returns `processed_ids` for the resume SQL (text() with batch only) - - records INSERTs so the test can count them - """ - inserted: list[dict] = [] - - db = MagicMock() - db.begin_nested.return_value.__enter__ = lambda self: self - db.begin_nested.return_value.__exit__ = lambda self, *a: False - - def execute_side_effect(sql, params=None): - sql_str = str(sql) - result = MagicMock() - if "FROM houses h" in sql_str or "houses_in_districts" in sql_str: - result.mappings.return_value.all.return_value = initial_sample - elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str: - # Resume query — returns list of (house_id,) tuples. - result.all.return_value = [(hid,) for hid in processed_ids] - elif "INSERT INTO address_mismatch_audit" in sql_str: - inserted.append(dict(params)) - # Simulate ON CONFLICT DO NOTHING — track id locally for re-run. - processed_ids.add(params["house_id"]) - result = MagicMock() - elif "ST_Distance" in sql_str: - result.first.return_value = (42.0,) - else: - result = MagicMock() - return result - - db.execute.side_effect = execute_side_effect - db.commit = MagicMock() - db.rollback = MagicMock() - db.close = MagicMock() - return db, inserted - - -async def test_audit_script_resumable(monkeypatch): - """Run main() twice with the same batch — second pass inserts 0.""" - sample = [ - { - "id": 1, - "address": "ул Малышева 51", - "lat": 56.838, - "lon": 60.586, - "district": "Кировский", - }, - {"id": 2, "address": "ул Ленина 5", "lat": 56.840, "lon": 60.600, "district": "Ленинский"}, - ] - processed_ids: set[int] = set() - db, inserted = _make_db_mock(sample, processed_ids) - - # Force API mode without needing a real key. - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - - fake_result = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838004, - snapped_lon=60.586155, - raw={"ok": True}, - ) - - with ( - patch("scripts.audit_address_mismatch.SessionLocal", return_value=db), - patch( - "scripts.audit_address_mismatch.reverse_via_api", - new=AsyncMock(return_value=fake_result), - ), - ): - # First run — both rows processed. - n1 = await main(["--batch", "test_batch_1", "--mode", "api"]) - assert n1 == 2 - assert len(inserted) == 2 - - # Second run with same batch — nothing left to do. - inserted.clear() - n2 = await main(["--batch", "test_batch_1", "--mode", "api"]) - assert n2 == 0 - assert inserted == [] - - -async def test_audit_script_api_mode_marks_error(monkeypatch): - """When the reverse call raises, the row is still inserted with status=error.""" - sample = [ - { - "id": 99, - "address": "ул Малышева 51", - "lat": 56.838, - "lon": 60.586, - "district": "Кировский", - }, - ] - processed_ids: set[int] = set() - db, inserted = _make_db_mock(sample, processed_ids) - - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - - with ( - patch("scripts.audit_address_mismatch.SessionLocal", return_value=db), - patch( - "scripts.audit_address_mismatch.reverse_via_api", - new=AsyncMock(side_effect=httpx.HTTPError("boom")), - ), - ): - n = await main(["--batch", "err_batch", "--mode", "api"]) - assert n == 1 - - assert len(inserted) == 1 - assert inserted[0]["audit_status"] == "error" - assert "boom" in (inserted[0]["error_message"] or "") - - -# --------------------------------------------------------------------------- -# Internal _run_api_mode no-match path -# --------------------------------------------------------------------------- - - -async def test_api_mode_no_match_path(): - """If Yandex returns address=None, row goes in with status=no_match.""" - sample = [SampleRow(id=7, address="ул X 1", lat=56.0, lon=60.0, district="Кировский")] - processed_ids: set[int] = set() - db, inserted = _make_db_mock([], processed_ids) - - res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True}) - - with patch( - "scripts.audit_address_mismatch.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_api_mode(db, sample, "b1", "key") - - assert n == 1 - assert inserted[0]["audit_status"] == "no_match" - assert inserted[0]["snapped_address"] is None diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py new file mode 100644 index 00000000..090b9422 --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -0,0 +1,1416 @@ +"""Integration tests for #2552 auth-core: POST /login, /logout, dual-mode GET /me, +and rbac_guard session-cookie resolution. + +Uses the REAL `rbac_guard` (app.core.rbac) + REAL `auth.router` / `me.router` wired +into an isolated FastAPI test app (same pattern as tests/test_rbac.py), with an +in-memory fake DB standing in for the identity registry: + - сессия РЕЕСТРА подменяется на самом низком уровне — `identity_store.SessionLocal` + и `auth_db.auth_session` (см. `tests.support.identity_modes.patch_identity_sessions`), + так что и `identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и + `Depends(get_identity_db)` (auth.py / me.py) выполняются РЕАЛЬНЫЕ, вместе со своим + ветвлением по `settings.identity_store`; + - `app.core.db.get_db` переопределён через `app.dependency_overrides` — это + продуктовая БД (в дефолтном режиме она же и реестр). + +Все они смотрят в ОДИН `_Store` на тест, поэтому сессия, созданная POST /login, +сразу видна собственному DB-раунд-трипу rbac_guard'а на следующем запросе. + +⚠️ ДВА РЕЖИМА РЕЕСТРА И ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а +эпик «единый вход» переименовывает таблицы (`tradein_users`/`tradein_sessions` → +`users`/`sessions`) и меняет тип колонки состояния доступа. Литерал +«tradein_users» в диспатчере означал бы, что при `IDENTITY_STORE=auth` ветка +молча перестаёт матчиться, fake отдаёт пустоту, а тест остаётся ЗЕЛЁНЫМ на +сломанном коде. Поэтому имена берутся из `sql_names()` (= `identity_schema()`, +тот же словарь, что у продакшн-кода), а непонятый SQL падает `AssertionError`, +а не возвращает пустой результат. + +Дефолт (`identity_store="tradein"`) — сегодняшний прод; тесты без фикстуры +`auth_store` идут именно в нём. Тесты про режим `auth` (в т.ч. про состояние +`trial_expired`, невыразимое булевым `is_active`) — в конце файла. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import time +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Annotated, Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import httpx +import pytest +from fastapi import FastAPI, Header +from fastapi.testclient import TestClient + +from app.api.v1 import auth as auth_router +from app.api.v1 import me as me_router +from app.core import auth as auth_mod +from app.core import auth_db, config +from app.core import password as password_mod +from app.core.db import get_db +from app.core.password import hash_password +from app.core.rbac import rbac_guard +from app.services.identity_store import AccessState +from tests.support.identity_modes import ( + assert_reads_access_state, + column_value, + patch_identity_sessions, + sql_names, + use_identity_mode, +) + +# --------------------------------------------------------------------------- +# Fake DB backing the identity registry (users/sessions таблицы текущего режима) +# --------------------------------------------------------------------------- + + +class _Store: + def __init__(self) -> None: + self.users: dict[str, dict[str, Any]] = {} + self.sessions: dict[str, dict[str, Any]] = {} + self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов + self._next_id = 1 + + def add_user( + self, + username: str, + password_hash: str | None, + *, + role: str = "employee", + access_state: AccessState = AccessState.ACTIVE, + display_name: str | None = "Alice A.", + org_name: str | None = "Org LLC", + email: str | None = "alice@example.com", + ) -> int: + uid = self._next_id + self._next_id += 1 + self.users[username] = { + "id": uid, + "username": username, + "password_hash": password_hash, + "role": role, + # СЫРОЕ значение колонки текущего режима (boolean либо text) — ровно + # то, что вернул бы драйвер; в AccessState его превращает код. + "access_state": column_value(access_state), + "display_name": display_name, + "org_name": org_name, + "email": email, + } + return uid + + def set_access_state(self, username: str, state: AccessState) -> None: + """Меняет состояние доступа уже заведённого юзера (как сделал бы админ/миграция).""" + self.users[username]["access_state"] = column_value(state) + + def user_by_id(self, uid: int) -> dict[str, Any] | None: + for u in self.users.values(): + if u["id"] == uid: + return u + return None + + def add_expired_session(self, token: str, user_id: int) -> None: + now = datetime.now(UTC) + self.sessions[token] = { + "user_id": user_id, + "expires_at": now - timedelta(minutes=1), + "last_seen_at": now - timedelta(minutes=1), + } + + +class _FakeDB: + """Minimal Session stand-in dispatching on SQL text — see module docstring.""" + + def __init__(self, store: _Store) -> None: + self.store = store + + def __enter__(self) -> _FakeDB: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def close(self) -> None: + pass + + def commit(self) -> None: + pass + + def rollback(self) -> None: + pass + + def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace: + sql = str(stmt) + p = params or {} + # Имена таблиц берутся ИЗ КОДА (identity_schema), а не из литералов — + # см. «ЛОВУШКА FAKE-DB» в модульном docstring. + names = sql_names() + self.store.sql_log.append(sql) + + if f"INSERT INTO {names.sessions}" in sql: + now = datetime.now(UTC) + self.store.sessions[p["token"]] = { + "user_id": p["user_id"], + "expires_at": now + timedelta(hours=p["ttl_hours"]), + "last_seen_at": now, + } + return SimpleNamespace(fetchone=lambda: None) + + if f"UPDATE {names.sessions}" in sql and "SET last_seen_at" in sql: + sess = self.store.sessions.get(p["token"]) + if sess is not None: + now = datetime.now(UTC) + sess["last_seen_at"] = now + sess["expires_at"] = now + timedelta(hours=p["ttl_hours"]) + return SimpleNamespace(fetchone=lambda: None) + + if f"DELETE FROM {names.sessions} WHERE token" in sql: + self.store.sessions.pop(p["token"], None) + return SimpleNamespace(fetchone=lambda: None) + + if f"DELETE FROM {names.sessions} WHERE user_id" in sql: + uid = p["user_id"] + for tok in [t for t, s in self.store.sessions.items() if s["user_id"] == uid]: + del self.store.sessions[tok] + return SimpleNamespace(fetchone=lambda: None) + + if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql: + assert_reads_access_state(sql, names) + sess = self.store.sessions.get(p["token"]) + if sess is None: + return SimpleNamespace(fetchone=lambda: None) + user = self.store.user_by_id(sess["user_id"]) + if user is None: + return SimpleNamespace(fetchone=lambda: None) + # Колонка состояния приезжает под алиасом `access_state` в ОБОИХ + # режимах (`u.<колонка> AS access_state` в реальном SELECT'е); + # значение — сырое, типа своей схемы. + row = SimpleNamespace( + user_id=sess["user_id"], + expires_at=sess["expires_at"], + last_seen_at=sess["last_seen_at"], + username=user["username"], + role=user["role"], + display_name=user["display_name"], + org_name=user["org_name"], + email=user["email"], + access_state=user["access_state"], + ) + return SimpleNamespace(fetchone=lambda: row) + + if f"FROM {names.users}" in sql and "WHERE username = :username" in sql: + assert_reads_access_state(sql, names) + user = self.store.users.get(p["username"]) + if user is None: + return SimpleNamespace(fetchone=lambda: None) + row = SimpleNamespace(**user) + return SimpleNamespace(fetchone=lambda: row) + + raise AssertionError(f"unhandled fake SQL in test_auth_api: {sql!r}") + + +# --------------------------------------------------------------------------- +# Test app +# --------------------------------------------------------------------------- + + +def _build_test_app(store: _Store) -> FastAPI: + app = FastAPI() + app.middleware("http")(rbac_guard) + app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"]) + app.include_router(me_router.router, prefix="/api/v1", tags=["me"]) + + @app.get("/api/v1/trade-in/dummy") + async def tradein_dummy() -> dict: + return {"ok": True} + + @app.get("/api/v1/trade-in/whoami") + async def tradein_whoami( + x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, + ) -> dict: + """Echoes the X-Authenticated-User header exactly as a downstream handler + (`_assert_estimate_access*`, `account_quota`, etc.) would see it — used to + assert session-identity wins over a client-forged header (#2552 spoof fix).""" + return {"user": x_authenticated_user} + + # Внутренние инструменты, закрытые от клиентских DB-ролей 2026-07-31 + # («Доля в продаже» / «Кэш»). Ручки настоящие (не заглушки rbac_guard'а), + # чтобы 403 приходил именно от scope-чека, а не от отсутствия роута. + @app.get("/api/v1/buildings/sale-share") + async def buildings_sale_share() -> dict: + return {"ok": True} + + @app.get("/api/v1/trade-in/cache-stats") + async def tradein_cache_stats() -> dict: + return {"ok": True} + + def _override_get_db(): # generator dependency — matches app.core.db.get_db shape + yield _FakeDB(store) + + app.dependency_overrides[get_db] = _override_get_db + return app + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + auth_mod.reset_cache_for_tests() + auth_router._LOGIN_LIMITER._hits.clear() + auth_router._USERNAME_FAIL_LIMITER._hits.clear() + monkeypatch.setattr(config.settings, "auth_mode", "dual") + # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже + # если предыдущий переключался на `auth`. + use_identity_mode(monkeypatch, "tradein") + + +@pytest.fixture +def store() -> _Store: + return _Store() + + +@pytest.fixture +def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store: + """Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние). + + Запрашивай ПЕРЕД `client` в списке аргументов теста: `client` строится уже с + учётом режима (`_build_test_app` читает его лениво, но `store.add_user` + сохраняет значение колонки по режиму НА МОМЕНТ ВЫЗОВА). + """ + use_identity_mode(monkeypatch, "auth") + return store + + +@pytest.fixture +def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient: + # Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по + # режиму: `identity_session()` / `get_identity_db()` остаются настоящими. + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + # base_url=https:// — login sets the session cookie with Secure=True (real prod + # behaviour, not weakened for tests); httpx's cookie jar silently drops Secure + # cookies on a plain-http connection, so a plain http://testserver client would + # never resend the cookie on subsequent requests within the same test. + return TestClient(_build_test_app(store), base_url="https://testserver") + + +# --------------------------------------------------------------------------- +# POST /login +# --------------------------------------------------------------------------- + + +def test_login_happy_path_sets_cookie(client: TestClient, store: _Store) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + + resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + assert resp.status_code == 200, resp.text + assert resp.json() == {"ok": True} + cookie_name = config.settings.session_cookie_name + assert cookie_name in resp.cookies + assert resp.cookies[cookie_name] + # Сессия реально создана в сторе под этим токеном. + assert resp.cookies[cookie_name] in store.sessions + + +def test_login_wrong_password_401_and_logs_failed_event( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + events: list[dict[str, Any]] = [] + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw)) + + resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "wrong"}) + + assert resp.status_code == 401 + assert "detail" in resp.json() + assert config.settings.session_cookie_name not in resp.cookies + assert any(e["event_type"] == "login_failed" for e in events) + failed = next(e for e in events if e["event_type"] == "login_failed") + assert failed["username"] == "alice" + # Raw-пароль никогда не попадает в событие. + assert "wrong" not in str(failed) + + +def test_login_unknown_username_401_generic_message(client: TestClient) -> None: + resp = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "whatever"}) + assert resp.status_code == 401 + # НЕ раскрываем, что юзера не существует — то же сообщение, что и wrong-password. + body_ghost = resp.json()["detail"] + + resp2 = client.post("/api/v1/auth/login", json={"username": "ghost2", "password": "x"}) + assert resp2.json()["detail"] == body_ghost + + +def test_login_inactive_user_401(client: TestClient, store: _Store) -> None: + store.add_user( + "bob", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED + ) + resp = client.post("/api/v1/auth/login", json={"username": "bob", "password": "Secret123!"}) + assert resp.status_code == 401 + + +def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None: + store.add_user("carol", None, role="employee") + resp = client.post("/api/v1/auth/login", json={"username": "carol", "password": "anything"}) + assert resp.status_code == 401 + + +def test_login_always_calls_verify_password_timing_oracle_guard( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """#2552 post-review Medium 2: `verify_password` должен выполняться ровно + один раз на КАЖДУЮ попытку логина — включая неизвестный username и NULL + password_hash — иначе короткое замыкание даёт наблюдаемый timing-oracle + для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет + сам факт + аргумент вызова через monkeypatch-счётчик.""" + store.add_user("alice", hash_password("Secret123!"), role="employee") + store.add_user("nullhash", None, role="employee") + + calls: list[str] = [] + real_verify = password_mod.verify_password + + def _counting_verify(plain: str, hashed: str) -> bool: + calls.append(hashed) + return real_verify(plain, hashed) + + # Патчим тело в app.core.password, а не имя в auth: с #2665 хендлер зовёт + # `verify_password_bounded`, а та ищет `verify_password` в своём модуле на + # каждый вызов — так счётчик считает РЕАЛЬНЫЕ bcrypt-сверки, а не обёртку. + monkeypatch.setattr(password_mod, "verify_password", _counting_verify) + + resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}) + assert resp_unknown.status_code == 401 + + resp_null_hash = client.post( + "/api/v1/auth/login", json={"username": "nullhash", "password": "x"} + ) + assert resp_null_hash.status_code == 401 + + resp_wrong_pw = client.post( + "/api/v1/auth/login", json={"username": "alice", "password": "wrong"} + ) + assert resp_wrong_pw.status_code == 401 + + assert len(calls) == 3 + # Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL. + assert calls[0] == auth_router._DUMMY_PASSWORD_HASH + assert calls[1] == auth_router._DUMMY_PASSWORD_HASH + # Реальный юзер с реальным hash — НЕ dummy. + assert calls[2] != auth_router._DUMMY_PASSWORD_HASH + + +def test_login_rate_limit_429(client: TestClient, store: _Store) -> None: + store.add_user("dave", hash_password("Secret123!"), role="employee") + limit = config.settings.login_rate_limit + + for _ in range(limit): + resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"}) + assert resp.status_code == 401 + + resp = client.post("/api/v1/auth/login", json={"username": "dave", "password": "wrong"}) + assert resp.status_code == 429 + assert "Retry-After" in resp.headers + + +# --------------------------------------------------------------------------- +# #2571 — глобальный потолок попыток НА ИМЯ (распределённый перебор) +# --------------------------------------------------------------------------- + + +def _throttle_settings( + monkeypatch: pytest.MonkeyPatch, *, threshold: int, max_delay_s: float +) -> None: + """Порог и потолок задержки под тест: реальные 20/час и 8с ждать некогда.""" + monkeypatch.setattr(config.settings, "login_username_fail_threshold", threshold) + monkeypatch.setattr(config.settings, "login_username_throttle_max_delay_s", max_delay_s) + + +def _capture_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw)) + return events + + +def _failed_login_delays(events: list[dict[str, Any]]) -> list[float]: + return [e["payload"]["throttle_delay_s"] for e in events if e["event_type"] == "login_failed"] + + +def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None: + """Задержка = 0 до порога, дальше удваивается от 1с и упирается в потолок. + + Потолок обязателен: без него растущая задержка вырождается в ту самую + блокировку учётки, которой #2571 сознательно избегает (DoS против человека). + """ + _throttle_settings(monkeypatch, threshold=3, max_delay_s=4.0) + + assert auth_router._throttle_delay_s(1) == 0.0 + assert auth_router._throttle_delay_s(3) == 0.0 # ровно порог — ещё не перебор + assert auth_router._throttle_delay_s(4) == 1.0 + assert auth_router._throttle_delay_s(5) == 2.0 + assert auth_router._throttle_delay_s(6) == 4.0 + assert auth_router._throttle_delay_s(7) == 4.0 # потолок + assert auth_router._throttle_delay_s(1000) == 4.0 + # Счётчик ничем не ограничен сверху (`record()` только добавляет метку), а + # `min()` вычисляет ОБА аргумента. Без зажатого показателя степени + # `float(2 ** (excess - 1))` при ~1045 неудачах падает с OverflowError, и + # защита начинает отдавать 500 без задержки и без аудита — ровно под той + # нагрузкой, ради которой писалась. 1000 выше проходило впритык под обрывом. + assert auth_router._throttle_delay_s(5_000) == 4.0 + assert auth_router._throttle_delay_s(10**6) == 4.0 + + +def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 1: перебор одного имени с РАЗНЫХ адресов упирается в потолок на имя. + + Каждая попытка приходит со своего IP, поэтому per-(username, IP) лимит + (`_LOGIN_LIMITER`, 5/300с) не срабатывает НИ РАЗУ — ровно та дыра, из-за + которой issue заведена. Ловит её только глобальный счётчик на имя. + """ + store.add_user("victim", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=3, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + for i in range(6): + resp = client.post( + "/api/v1/auth/login", + json={"username": "victim", "password": f"guess{i}"}, + headers={"x-forwarded-for": f"10.0.0.{i}"}, + ) + # per-IP бюджет не исчерпан ни на одном адресе — никаких 429. + assert resp.status_code == 401, resp.text + + delays = _failed_login_delays(events) + assert len(delays) == 6 + # Первые три — в пределах порога, дальше замедление. + assert delays[:3] == [0.0, 0.0, 0.0] + assert all(d > 0 for d in delays[3:]) + # Счётчик глобальный: считает попытки со ВСЕХ адресов, а не по каждому свой. + fails = [e["payload"]["username_fails_in_window"] for e in events] + assert fails == [1, 2, 3, 4, 5, 6] + + +def test_throttle_actually_delays_the_response( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Задержка именно ЖДЁТСЯ, а не только пишется в аудит. + + Отдельный тест, потому что все остальные читают величину задержки из + события: без этой проверки реализация, которая посчитала задержку и забыла + `await`, оставила бы весь набор зелёным. + """ + store.add_user("slowpoke", hash_password("Secret123!"), role="employee") + # threshold=0 → тормозим уже первую неудачу; потолок 1с — цена одного теста. + _throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0) + + started = time.monotonic() + resp = client.post("/api/v1/auth/login", json={"username": "slowpoke", "password": "wrong"}) + elapsed = time.monotonic() - started + + assert resp.status_code == 401 + assert elapsed >= 1.0 + + +def test_db_connection_released_before_sleeping( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Соединение с БД возвращается в пул ДО сна, а не удерживается всю задержку. + + На проде `get_identity_db` в дефолтном режиме отдаёт ту же сессию, что + `get_db` (движок с QueuePool 5+10, pool_timeout=30), а `get_user_by_username` + оставляет её в открытой транзакции. Сон внутри этой области жизни держал бы + соединение занятым все 8с: ~15 одновременно спящих неудач выбирают пул + целиком, и дальше ЛЮБОЙ эндпоинт ждёт checkout 30с и падает — отказ в + обслуживании против всех, ради ухода от которого замедление и выбиралось + вместо блокировки. + + Проверяем порядком, а не мокой пула: если `close()` случился до сна, между + ним и концом ответа лежит вся задержка; если бы сессию закрывала только + зависимость (то есть после сна) — зазор был бы околонулевым. + """ + store.add_user("holder", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0) + + closes: list[float] = [] + real_close = _FakeDB.close + + def _spy_close(self: _FakeDB) -> None: + closes.append(time.monotonic()) + real_close(self) + + monkeypatch.setattr(_FakeDB, "close", _spy_close) + + resp = client.post("/api/v1/auth/login", json={"username": "holder", "password": "wrong"}) + finished = time.monotonic() + + assert resp.status_code == 401 + assert closes, "сессия не закрывалась вовсе" + assert finished - closes[0] >= 1.0 + + +def test_typo_does_not_throttle_and_correct_password_still_works( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 2: живой человек с опечаткой не наказан. + + Порог штатный (20/час), три опечатки до него не дотягивают — задержки нет, + а верный пароль пускает сразу же. Замедление применяется ТОЛЬКО к неудаче, + поэтому даже перебранный порог не мешает владельцу пароля войти. + """ + store.add_user("erin", hash_password("Secret123!"), role="employee") + events = _capture_events(monkeypatch) + + for _ in range(3): + resp = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123"}) + assert resp.status_code == 401 + + assert _failed_login_delays(events) == [0.0, 0.0, 0.0] + + ok = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123!"}) + assert ok.status_code == 200, ok.text + assert config.settings.session_cookie_name in ok.cookies + + +def test_counter_decays_when_window_passes(monkeypatch: pytest.MonkeyPatch) -> None: + """Вторая половина DoD 2: наказание не накапливается вечно. + + Окно скользящее, старые неудачи выпадают сами — снимать ничего вручную не + нужно. Проверяем на самом счётчике, а не через HTTP: один вызов login стоит + полного bcrypt (~0.25с), так что игрушечное окно истекало бы прямо посреди + цикла запросов и тест мерил бы скорость хеширования, а не спад счётчика. + """ + _throttle_settings(monkeypatch, threshold=1, max_delay_s=4.0) + limiter = auth_router._USERNAME_FAIL_LIMITER + monkeypatch.setattr(limiter, "_window_s", 0.2) + + assert [limiter.record("frank") for _ in range(3)] == [1, 2, 3] + assert auth_router._throttle_delay_s(3) > 0 + + time.sleep(0.25) # окно прошло — прошлые неудачи больше не считаются + + assert limiter.record("frank") == 1 + assert auth_router._throttle_delay_s(1) == 0.0 + + +def test_username_length_is_bounded(client: TestClient) -> None: + """Сырое имя становится ключом обоих лимитеров, а их словарь чистится только + при >10000 ключей и только от пустых корзин — при окне в час чистить нечего. + Границу длины держим на 64 (верх CHECK'а реестра), чтобы килобайтные имена + не растили память ключами.""" + resp = client.post("/api/v1/auth/login", json={"username": "x" * 65, "password": "p"}) + assert resp.status_code == 422 + # 64 — всё ещё валидная длина, отвечаем обычным generic-отказом. + ok_len = client.post("/api/v1/auth/login", json={"username": "x" * 64, "password": "p"}) + assert ok_len.status_code == 401 + + +def test_throttle_identical_for_existing_and_unknown_username( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Замедление не должно стать оракулом существования учётки. + + Если тормозить только существующие имена, злоумышленник перечисляет живые + логины по времени ответа, не зная ни одного пароля — та же user-enumeration, + от которой защищают одинаковый 401 и безусловный bcrypt (`_DUMMY_PASSWORD_HASH`), + только по другому каналу. Счётчик ведётся по присланному имени БЕЗ проверки + в реестре, поэтому обе последовательности обязаны совпасть. + """ + store.add_user("real", hash_password("Secret123!"), role="employee") + _throttle_settings(monkeypatch, threshold=2, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + real_responses = [] + for i in range(4): + real_responses.append( + client.post( + "/api/v1/auth/login", + json={"username": "real", "password": "wrong"}, + headers={"x-forwarded-for": f"10.1.0.{i}"}, + ) + ) + real_delays = _failed_login_delays(events) + + events.clear() + ghost_responses = [] + for i in range(4): + ghost_responses.append( + client.post( + "/api/v1/auth/login", + json={"username": "ghost", "password": "wrong"}, + headers={"x-forwarded-for": f"10.2.0.{i}"}, + ) + ) + ghost_delays = _failed_login_delays(events) + + assert real_delays == ghost_delays + assert real_delays[:2] == [0.0, 0.0] and all(d > 0 for d in real_delays[2:]) + # И «наблюдаемая» часть ответа тоже неотличима. + assert [r.status_code for r in real_responses] == [r.status_code for r in ghost_responses] + assert {r.json()["detail"] for r in real_responses} == { + r.json()["detail"] for r in ghost_responses + } + + +def test_disabled_account_throttled_like_wrong_password( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Отказ по «доступ закрыт» идёт тем же хвостом: счётчик, аудит, задержка. + + Иначе быстрый 401 у заблокированной учётки отличал бы её от несуществующей. + """ + store.add_user( + "blocked", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED + ) + _throttle_settings(monkeypatch, threshold=1, max_delay_s=0.01) + events = _capture_events(monkeypatch) + + for i in range(2): + # Пароль ВЕРНЫЙ — отказ приходит именно из ветки состояния доступа. + resp = client.post( + "/api/v1/auth/login", + json={"username": "blocked", "password": "Secret123!"}, + headers={"x-forwarded-for": f"10.3.0.{i}"}, + ) + assert resp.status_code == 401 + + assert _failed_login_delays(events) == [0.0, pytest.approx(0.01)] + + +def test_failed_login_events_reach_audit_with_counter_state( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """DoD 3: неудачные входы видны в аудите — с ip/ua и состоянием счётчика. + + Именно счётчик отличает распределённый перебор от россыпи случайных + одиночных неудач: в `user_events` иначе видно только «по одной неудаче с + каждого адреса», что само по себе не тревожно. + """ + store.add_user("audited", hash_password("Secret123!"), role="employee") + events = _capture_events(monkeypatch) + + resp = client.post( + "/api/v1/auth/login", + json={"username": "audited", "password": "s3cret-typo"}, + # Левый хоп подделан клиентом, правый добавлен Caddy — в аудит обязан + # попасть ПРАВЫЙ (`ratelimit._client_ip`, допущение «ровно один прокси»). + # Иначе злоумышленник переписывал бы себе ip в журнале одним заголовком. + headers={ + "x-forwarded-for": "198.51.100.9, 203.0.113.7", + "user-agent": "probe/1.0", + }, + ) + + assert resp.status_code == 401 + (failed,) = (e for e in events if e["event_type"] == "login_failed") + assert failed["username"] == "audited" + assert failed["ip"] == "203.0.113.7" + assert failed["user_agent"] == "probe/1.0" + assert failed["path"] == "/api/v1/auth/login" + assert failed["payload"]["username_fails_in_window"] == 1 + # Raw-пароль не утекает ни в поля события, ни в payload. + assert "s3cret-typo" not in str(failed) + + +# --------------------------------------------------------------------------- +# #2665 — настоящий потолок ТЕМПА проверок пароля + свободный событийный цикл +# --------------------------------------------------------------------------- + + +async def test_login_flood_capped_by_rate_while_api_stays_responsive( + store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Сто одновременных соединений не получают больше N попыток В СЕКУНДУ, и при + этом остальной API продолжает отвечать. + + ОБА утверждения в одном тесте намеренно — по отдельности каждое зелено на + сломанной системе: + - только про темп: сегодняшний код (bcrypt прямо в `async def`) тоже + держит темп низким — ценой того, что весь API стоит; + - только про отзывчивость: `asyncio.to_thread` без потолка освобождает + цикл и одновременно РАЗГОНЯЕТ перебор (замер на проде: 3.6 → 16 + проверок/с). + Убери любую половину правки — тест обязан покраснеть. + + Проверяем ТЕМП, а не латентность: задержка из #2571 (`await asyncio.sleep`) + латентность растит, а темп не ограничивает вовсе — сто соединений отспят её + параллельно. Поэтому меряем ЧИСЛО состоявшихся bcrypt-сверок за секунду + непрерывного флуда, а не время одного ответа. + + Каждый запрос идёт со СВОЕЙ парой (username, ip). Это худший случай для + защит #2571 и он же реалистичный: при credential stuffing ни лимит на + (username, IP), ни счётчик неудач на имя не срабатывают ни разу — с чужого + адреса и с новым именем бюджет всегда свежий. Значит меряем ровно новый + потолок, а не соседний лимитер. + """ + verify_s = 0.05 + # Потолок = размер пула / время одной сверки. Значение берётся из ТОЙ ЖЕ + # настройки, что его задаёт, поэтому этот тест проверяет только МЕХАНИКУ + # (потолок работает и равен пулу), но НЕ величину дефолта: подними + # login_password_verify_workers — поднимется и ожидание, тест останется + # зелёным. Сам дефолт стережёт + # tests/test_password.py::test_verify_ceiling_defaults_stay_within_the_db_pool. + ceiling_per_s = config.settings.login_password_verify_workers / verify_s + + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + _capture_events(monkeypatch) + app = _build_test_app(store) + + attempts: list[float] = [] + + def _slow_verify(plain: str, hashed: str) -> bool: + """Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. + + Блокирующий `time.sleep`, а не `await` — суть проблемы в том, что bcrypt + не отпускает поток; двойник с `await` проверял бы не то. + """ + attempts.append(time.monotonic()) + time.sleep(verify_s) + return False + + monkeypatch.setattr(password_mod, "verify_password", _slow_verify) + + probe_latencies: list[float] = [] + flood_over = asyncio.Event() + + async def probe(client: httpx.AsyncClient) -> None: + """Сторонний (не login) запрос раз в 10мс — детектор занятости цикла.""" + while not flood_over.is_set(): + t0 = time.monotonic() + await client.get("/api/v1/trade-in/dummy") + probe_latencies.append(time.monotonic() - t0) + await asyncio.sleep(0.01) + + duration_s = 1.0 + connections = 100 + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://testserver" + ) as client: + + async def attacker(n: int) -> list[int]: + codes: list[int] = [] + i = 0 + while time.monotonic() < deadline: + i += 1 + resp = await client.post( + "/api/v1/auth/login", + json={"username": f"spray{n}x{i}", "password": "guess"}, + headers={"x-forwarded-for": f"10.{n % 250}.{i % 250}.7"}, + ) + codes.append(resp.status_code) + await asyncio.sleep(0.005) + return codes + + started = time.monotonic() + deadline = started + duration_s + probe_task = asyncio.create_task(probe(client)) + code_lists = await asyncio.gather(*(attacker(n) for n in range(connections))) + elapsed = time.monotonic() - started + flood_over.set() + await probe_task + + codes = [c for lst in code_lists for c in lst] + attempts_per_s = len(attempts) / elapsed + + # 1. Событийный цикл СВОБОДЕН всё это время. С bcrypt внутри `async def` + # сторонний запрос ждёт столько, сколько длится очередь сверок. + # Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда + # остальные числа мерят не потолок, а паралич — их надо читать после + # этого вердикта, а не вместо него. + assert probe_latencies, "проба не сделала ни одного запроса" + probe_latencies.sort() + assert ( + probe_latencies[-1] < 0.5 + ), f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" + median_probe = probe_latencies[len(probe_latencies) // 2] + assert median_probe < verify_s, ( + f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной " + f"сверки — цикл занят проверкой пароля, API стоит" + ) + # Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс. + assert ( + len(probe_latencies) >= 10 + ), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" + + # 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их + # доехало не больше потолка (запас ×1.5 на планировщик). + assert len(codes) > connections, ( + "флуд не состоялся: на каждое соединение вышло не больше одного ответа — " + "мерить потолок не на чем" + ) + assert attempts_per_s <= ceiling_per_s * 1.5, ( + f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с " + f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает" + ) + # 3. Лишнее ОТКЛОНЯЕТСЯ, а не копится в очереди: очередь держала бы + # соединения к БД и выбрала бы пул (QueuePool 5+10). + assert codes.count(429) > codes.count(401), "избыток должен получать 429, а не ждать" + # 4. Потолок режет ТЕМП, а не обнуляет попытки: до bcrypt доезжает хоть + # что-то. Это утверждение ПРО АТАКУЮЩЕГО и ни слова не говорит о том, + # войдёт ли в это время живой человек — он не войдёт, пока слоты заняты + # флудом (#2714). Проверка «дверь открыта своим» — отдельным тестом ниже, + # и мерит она вход С ДРУГОГО КЛЮЧА, а не число попыток атакующего. + assert len(attempts) >= 2 + + +# --------------------------------------------------------------------------- +# #2714 — потолок не должен бить по своим: доля слотов на один ключ +# --------------------------------------------------------------------------- + + +async def test_flood_from_one_ip_leaves_login_open_for_another_ip( + store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Пока один адрес непрерывно флудит, человек с ДРУГОГО адреса входит (#2714). + + Именно это ломал потолок #2665 в исходном виде: слоты — общий котёл, флуд + занимал все четыре, и легитимный вход с ВЕРНЫМ паролем получал 429 столько + раз, сколько пытался (замер в issue: 20 попыток → 20×429, 0×200). + + Тест мерит ровно заявленное — ВХОД С ДРУГОГО КЛЮЧА, а не латентность одного + запроса и не число попыток атакующего: с общим котлом «попытки атакующего + доезжают» и «свой войдёт» — разные утверждения, и первое зелено, когда + второе ложно. + + Число попыток пробы = `login_rate_limit`, и это не подгонка: столько входов + по паре (имя, IP) вообще разрешено за окно соседним `_LOGIN_LIMITER`. + Просить больше значило бы мерить ЕГО 429 вместо потолка сверок — то есть + получить красный тест на исправном коде. + + Стенд БЕЗ `RateLimitMiddleware` (его в тестовом приложении нет), поэтому + флуд здесь плотнее, чем один адрес может выдать на проде (там его режут + 300 запросов за 60с). Так и задумано: проверяем худший случай. + """ + verify_s = 0.05 + flood_ip = "203.0.113.66" + legit_ip = "198.51.100.10" + legit_password = "Secret123!" + + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + _capture_events(monkeypatch) + legit_hash = hash_password(legit_password) + store.add_user("realuser", legit_hash, role="employee") + app = _build_test_app(store) + + def _slow_verify(plain: str, hashed: str) -> bool: + """Двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. + + Вердикт настоящий (а не всегда-False, как в тесте про темп выше) — без + него легитимный вход не дошёл бы до 200 и мерить было бы нечего. + """ + time.sleep(verify_s) + return hashed == legit_hash and plain == legit_password + + monkeypatch.setattr(password_mod, "verify_password", _slow_verify) + + flood_codes: list[int] = [] + flood_over = asyncio.Event() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://testserver" + ) as client: + + async def flooder(n: int) -> None: + i = 0 + while not flood_over.is_set(): + i += 1 + # Своё имя на каждую попытку — иначе флуд упрётся в + # `_LOGIN_LIMITER` (5 на пару имя+IP) и до потолка сверок не + # доедет вовсе: тест стал бы зелёным, ничего не проверив. + resp = await client.post( + "/api/v1/auth/login", + json={"username": f"nosuchuser{n}x{i}", "password": "guess"}, + headers={"x-forwarded-for": flood_ip}, + ) + flood_codes.append(resp.status_code) + + floods = [asyncio.create_task(flooder(n)) for n in range(8)] + try: + # Ждём ДОКАЗАННОГО насыщения: 429 у атакующего = слоты кончились. + # Без этого условия проба могла бы пройти по пустой очереди и тест + # был бы зелёным на сломанном коде. + saturation_deadline = time.monotonic() + 10.0 + while flood_codes.count(429) < 4: + assert time.monotonic() < saturation_deadline, ( + f"флуд не насытил слоты за 10с ({len(flood_codes)} ответов, " + f"429: {flood_codes.count(429)}) — мерить справедливость не на чем" + ) + await asyncio.sleep(0.01) + + probe_codes: list[int] = [] + for _ in range(config.settings.login_rate_limit): + resp = await client.post( + "/api/v1/auth/login", + json={"username": "realuser", "password": legit_password}, + headers={"x-forwarded-for": legit_ip}, + ) + probe_codes.append(resp.status_code) + finally: + flood_over.set() + await asyncio.gather(*floods) + + assert probe_codes.count(200) == len(probe_codes), ( + f"легитимный вход с {legit_ip} во время флуда с {flood_ip}: " + f"200={probe_codes.count(200)}, 429={probe_codes.count(429)}, " + f"401={probe_codes.count(401)} — потолок бьёт по своим (#2714)" + ) + + +# --------------------------------------------------------------------------- +# POST /logout +# --------------------------------------------------------------------------- + + +def test_logout_revokes_session_and_clears_cookie(client: TestClient, store: _Store) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + login_resp = client.post( + "/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"} + ) + token = login_resp.cookies[config.settings.session_cookie_name] + assert token in store.sessions + + logout_resp = client.post("/api/v1/auth/logout") + assert logout_resp.status_code == 200 + assert logout_resp.json() == {"ok": True} + assert token not in store.sessions + + +# --------------------------------------------------------------------------- +# GET /me — session-first + dual-mode legacy fallback +# --------------------------------------------------------------------------- + + +def test_me_with_session_cookie_returns_db_role(client: TestClient, store: _Store) -> None: + store.add_user( + "alice", + hash_password("Secret123!"), + role="employee", + display_name="Алиса", + org_name="ООО Ромашка", + email="alice@romashka.ru", + ) + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + resp = client.get("/api/v1/me") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["username"] == "alice" + assert body["role"] == "employee" + assert "/trade-in/**" in body["allowed_paths"] + assert "/admin/**" in body["deny_paths"] + # Пункты меню «Доля в продаже» / «Кэш» прячет Topbar, фильтруя NAV_ITEMS по + # deny_paths ИЗ /me — т.е. видимость держится на ЭТОМ выводе, а не только на + # DB_ROLE_PATHS. Сборка dict-а в app/api/v1/me.py может регрессировать + # независимо от get_db_role_scope, поэтому пиним её здесь. + assert "/trade-in/sale-share/**" in body["deny_paths"] + assert "/trade-in/cache/**" in body["deny_paths"] + assert body["display_name"] == "Алиса" + assert body["org"] == "ООО Ромашка" + assert body["email"] == "alice@romashka.ru" + + +def test_me_manager_role_gets_team_path(client: TestClient, store: _Store) -> None: + store.add_user("mgr", hash_password("Secret123!"), role="manager") + client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"}) + + resp = client.get("/api/v1/me") + assert resp.status_code == 200 + body = resp.json() + assert "/api/v1/team/**" in body["allowed_paths"] + # Тот же пин, что и для employee: manager (роль praktika) не должен получать + # из /me deny-список без внутренних разделов — иначе пункты вернутся в топбар. + assert "/trade-in/sale-share/**" in body["deny_paths"] + assert "/trade-in/cache/**" in body["deny_paths"] + + +def test_me_without_cookie_dual_mode_legacy_still_works(client: TestClient) -> None: + """Regression guard: без сессии, auth_mode=dual — legacy X-Authenticated-User + путь через roles.yaml работает БЕЗ ИЗМЕНЕНИЙ.""" + resp = client.get("/api/v1/me", headers={"X-Authenticated-User": "admin"}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["username"] == "admin" + assert body["role"] == "admin" + assert body["allowed_paths"] == ["/**"] + + +def test_me_no_cookie_no_header_401(client: TestClient) -> None: + resp = client.get("/api/v1/me") + assert resp.status_code == 401 + + +def test_expired_session_falls_back_to_no_auth(client: TestClient, store: _Store) -> None: + """Истёкшая сессия трактуется как отсутствие cookie: без legacy-заголовка — 401.""" + uid = store.add_user("alice", hash_password("Secret123!"), role="employee") + store.add_expired_session("expired-tok", uid) + + client.cookies.set(config.settings.session_cookie_name, "expired-tok") + resp = client.get("/api/v1/trade-in/dummy") + assert resp.status_code == 401 + + +def test_expired_session_falls_back_to_legacy_header_in_dual_mode( + client: TestClient, store: _Store +) -> None: + """Истёкшая сессия + валидный legacy header в dual-mode — header отрабатывает.""" + uid = store.add_user("alice", hash_password("Secret123!"), role="employee") + store.add_expired_session("expired-tok", uid) + + client.cookies.set(config.settings.session_cookie_name, "expired-tok") + resp = client.get( + "/api/v1/trade-in/dummy", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +# --------------------------------------------------------------------------- +# rbac_guard dual vs db_only +# --------------------------------------------------------------------------- + + +def test_db_only_mode_rejects_legacy_header_without_session( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(config.settings, "auth_mode", "db_only") + + resp = client.get( + "/api/v1/trade-in/dummy", + headers={"X-Authenticated-User": "admin"}, + ) + assert resp.status_code == 401 + assert "session" in resp.json()["detail"].lower() + + +def test_db_only_mode_accepts_valid_session( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + monkeypatch.setattr(config.settings, "auth_mode", "db_only") + + resp = client.get("/api/v1/trade-in/dummy") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store: _Store) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + ok = client.get("/api/v1/trade-in/dummy") + assert ok.status_code == 200 + + denied = client.get("/api/v1/admin/dummy") + # rbac_guard's admin-gate matches the path regex BEFORE routing even happens + # (route isn't registered on this test app) — role=employee != admin -> 403, + # never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo). + assert denied.status_code == 403 + + +# --------------------------------------------------------------------------- +# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов — +# СКВОЗЬ РЕАЛЬНЫЙ rbac_guard по SESSION-ветке (from_session=True). +# +# Тесты в tests/test_rbac.py проверяют матчеры напрямую + guard по ЛЕГАСИ +# trusted-header ветке (is_path_allowed / roles.yaml). Но в проде клиентские +# аккаунты (praktika и прочие DB-юзеры) ходят именно session-cookie'ой, где +# scope считает ДРУГАЯ ветка — `_db_role_path_allowed(role, external_path)`. +# Без тестов ниже её можно было сломать, не уронив ни одного теста. +# +# Пути тут — ВНУТРЕННИЕ (Caddy срезает внешний /trade-in), rbac_guard +# восстанавливает внешний как '/trade-in' + path. +# --------------------------------------------------------------------------- + +_INTERNAL_TOOL_API = ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats") + + +def test_session_manager_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None: + store.add_user("mgr", hash_password("Secret123!"), role="manager") + client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"}) + + for path in _INTERNAL_TOOL_API: + resp = client.get(path) + assert resp.status_code == 403, f"manager {path}: {resp.status_code} {resp.text}" + assert "forbidden for role" in resp.json()["detail"].lower() + + # ...и при этом основной продукт для той же сессии открыт (иначе тест выше + # проходил бы и на «сломали scope целиком»). + ok = client.get("/api/v1/trade-in/dummy") + assert ok.status_code == 200, ok.text + + +def test_session_employee_denied_on_internal_tool_api(client: TestClient, store: _Store) -> None: + store.add_user("emp", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "emp", "password": "Secret123!"}) + + for path in _INTERNAL_TOOL_API: + resp = client.get(path) + assert resp.status_code == 403, f"employee {path}: {resp.status_code} {resp.text}" + assert "forbidden for role" in resp.json()["detail"].lower() + + ok = client.get("/api/v1/trade-in/dummy") + assert ok.status_code == 200, ok.text + + +def test_session_admin_keeps_internal_tool_api(client: TestClient, store: _Store) -> None: + """Контрольная группа: DB-роль admin ('/**') разделы по-прежнему видит.""" + store.add_user("root", hash_password("Secret123!"), role="admin") + client.post("/api/v1/auth/login", json={"username": "root", "password": "Secret123!"}) + + for path in _INTERNAL_TOOL_API: + resp = client.get(path) + assert resp.status_code == 200, f"admin {path}: {resp.text}" + + +# --------------------------------------------------------------------------- +# #2552 post-review CRITICAL fix: session identity must win over a spoofed +# client-sent X-Authenticated-User header (was a skip-if-present bug — the +# forged header used to override the session for every downstream reader of +# the raw header: _assert_estimate_access*, account_quota, /trade-in/history, +# support.py — in BOTH auth_mode=dual and db_only). +# --------------------------------------------------------------------------- + + +def test_session_identity_wins_over_spoofed_header_dual_mode( + client: TestClient, store: _Store +) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + store.add_user("victim", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + resp = client.get( + "/api/v1/trade-in/whoami", + headers={"X-Authenticated-User": "victim"}, + ) + assert resp.status_code == 200 + assert resp.json()["user"] == "alice" + + +def test_session_identity_wins_over_spoofed_header_db_only_mode( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + store.add_user("alice", hash_password("Secret123!"), role="employee") + store.add_user("victim", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + monkeypatch.setattr(config.settings, "auth_mode", "db_only") + + resp = client.get( + "/api/v1/trade-in/whoami", + headers={"X-Authenticated-User": "victim"}, + ) + assert resp.status_code == 200 + assert resp.json()["user"] == "alice" + + +def test_cyrillic_username_session_propagation_does_not_500( + client: TestClient, store: _Store +) -> None: + """#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на + кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500.""" + store.add_user("алиса", hash_password("Secret123!"), role="employee") + login_resp = client.post( + "/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"} + ) + assert login_resp.status_code == 200, login_resp.text + + resp = client.get("/api/v1/trade-in/whoami") + assert resp.status_code == 200, resp.text + # latin-1 "replace" гарантированно не крашит — точное значение (что именно + # получится из non-latin1 байт) не является контрактом, важно отсутствие 500. + assert resp.json()["user"] is not None + + +# --------------------------------------------------------------------------- +# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`). +# +# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод — и служит регрессионным +# доказательством «после мержа работает точно как сейчас». Ниже — поведение, +# которое появляется ТОЛЬКО после переезда: трёхзначное состояние доступа +# (`active` / `trial_expired` / `disabled`) вместо булева `is_active`. +# --------------------------------------------------------------------------- + + +def test_default_mode_talks_to_tradein_tables_only(client: TestClient, store: _Store) -> None: + """Дефолт трогает РОВНО сегодняшние таблицы — и ни одной таблицы реестра `auth`. + + Пин на случай, если флаг когда-нибудь начнёт «протекать» (например, дефолт + поменяют или ветвление уедет не туда): расхождение здесь означало бы, что + прод после мержа пошёл в другую БД. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + assert client.get("/api/v1/me").status_code == 200 + + joined = "\n".join(store.sql_log) + assert "tradein_users" in joined + assert "tradein_sessions" in joined + # Ни один запрос не адресован таблицам общего реестра. + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined) + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined) + # И engine БД `auth` даже не создавался (AUTH_DATABASE_URL на проде пуст — + # ленивое построение обязано не случиться, иначе запрос упал бы). + assert auth_db._engine is None + + +def test_auth_mode_talks_to_shared_registry_tables(auth_store: _Store, client: TestClient) -> None: + """Зеркало предыдущего: при IDENTITY_STORE=auth запросы уходят в users/sessions.""" + auth_store.add_user("alice", hash_password("Secret123!"), role="employee") + resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + assert resp.status_code == 200, resp.text + assert client.get("/api/v1/me").status_code == 200 + + joined = "\n".join(auth_store.sql_log) + assert "tradein_users" not in joined + assert "tradein_sessions" not in joined + assert re.search(r"FROM\s+users\b", joined) + assert re.search(r"INSERT INTO\s+sessions\b", joined) + + +def test_login_trial_expired_403_with_code_and_no_session( + auth_store: _Store, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """ВЕРНЫЙ пароль + `trial_expired` → 403 с машиночитаемым кодом, сессии НЕТ. + + Единственный не-generic ответ логина: аккаунт существует и владелец это уже + доказал паролем, так что осмысленный текст постороннему ничего не выдаёт. + """ + auth_store.add_user( + "trialguy", + hash_password("Secret123!"), + role="employee", + access_state=AccessState.TRIAL_EXPIRED, + ) + events: list[dict[str, Any]] = [] + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw)) + + resp = client.post( + "/api/v1/auth/login", json={"username": "trialguy", "password": "Secret123!"} + ) + + assert resp.status_code == 403, resp.text + detail = resp.json()["detail"] + # Контракт для фронта — `code`, а не текст сообщения. + assert detail["code"] == "access_expired" + assert detail["message"] + # Сессия не выдана: ни куки, ни строки в реестре. + assert config.settings.session_cookie_name not in resp.cookies + assert auth_store.sessions == {} + assert [e["event_type"] for e in events] == ["login_blocked_expired"] + + +def test_login_wrong_password_on_trial_expired_is_generic_401( + auth_store: _Store, client: TestClient +) -> None: + """НЕверный пароль на `trial_expired` → тот же generic 401, что у чужого логина. + + Иначе отдельный 403 превращается в оракул существования аккаунта: перебором + можно было бы перечислить логины, не зная ни одного пароля. + """ + auth_store.add_user( + "trialguy", + hash_password("Secret123!"), + role="employee", + access_state=AccessState.TRIAL_EXPIRED, + ) + + wrong_pw = client.post("/api/v1/auth/login", json={"username": "trialguy", "password": "nope"}) + ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "nope"}) + + assert wrong_pw.status_code == 401 + # Побайтово тот же ответ, что и на несуществующий логин. + assert wrong_pw.json() == ghost.json() + assert auth_store.sessions == {} + + +def test_login_disabled_is_generic_401_not_403(auth_store: _Store, client: TestClient) -> None: + """`disabled` + верный пароль → generic 401, НЕ 403: заблокированный аккаунт + для пользователя неотличим от несуществующего (в отличие от `trial_expired`, + у которого есть свой экран).""" + auth_store.add_user( + "blocked", + hash_password("Secret123!"), + role="employee", + access_state=AccessState.DISABLED, + ) + + blocked = client.post( + "/api/v1/auth/login", json={"username": "blocked", "password": "Secret123!"} + ) + ghost = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"}) + + assert blocked.status_code == 401 + assert blocked.json() == ghost.json() + assert auth_store.sessions == {} + + +def test_unknown_access_state_is_fail_closed_401(auth_store: _Store, client: TestClient) -> None: + """Состояние, которого код не знает (миграция уехала вперёд кода), НЕ пускает.""" + auth_store.add_user("newbie", hash_password("Secret123!"), role="employee") + auth_store.users["newbie"]["access_state"] = "pending_review" + + resp = client.post("/api/v1/auth/login", json={"username": "newbie", "password": "Secret123!"}) + + assert resp.status_code == 401 + assert auth_store.sessions == {} + + +@pytest.mark.parametrize("state", [AccessState.TRIAL_EXPIRED, AccessState.DISABLED]) +def test_live_session_dies_when_access_state_leaves_active( + auth_store: _Store, client: TestClient, state: AccessState +) -> None: + """Уже выданная сессия перестаёт работать СРАЗУ, как только состояние != active. + + Без этого sliding-refresh (`get_session_user` продлевает expires_at на каждом + запросе) держал бы сессию истёкшего/заблокированного бесконечно долго. + """ + auth_store.add_user("alice", hash_password("Secret123!"), role="employee") + login = client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + assert login.status_code == 200 + assert client.get("/api/v1/trade-in/dummy").status_code == 200 + + auth_store.set_access_state("alice", state) + + # auth_mode=dual, но legacy-заголовка нет → сессия больше не резолвится → 401. + assert client.get("/api/v1/trade-in/dummy").status_code == 401 + assert client.get("/api/v1/me").status_code == 401 + + +def test_session_identity_wins_over_spoofed_header_auth_store( + auth_store: _Store, client: TestClient +) -> None: + """Перезапись X-Authenticated-User в ASGI-scope работает и на общем реестре. + + Тот же CRITICAL, что и в дефолтном режиме (см. выше): подделанный клиентом + заголовок не должен выигрывать у резолвленной сессии ни в одном режиме — эти + ~15 downstream-хендлеров читают сырой заголовок и про режим ничего не знают. + """ + auth_store.add_user("alice", hash_password("Secret123!"), role="employee") + auth_store.add_user("victim", hash_password("Secret123!"), role="employee") + client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"}) + + resp = client.get("/api/v1/trade-in/whoami", headers={"X-Authenticated-User": "victim"}) + + assert resp.status_code == 200 + assert resp.json()["user"] == "alice" + + +def test_auth_mode_role_scope_and_logout(auth_store: _Store, client: TestClient) -> None: + """Роль/скоуп и logout на общем реестре ведут себя как в дефолтном режиме.""" + auth_store.add_user("mgr", hash_password("Secret123!"), role="manager") + login = client.post("/api/v1/auth/login", json={"username": "mgr", "password": "Secret123!"}) + token = login.cookies[config.settings.session_cookie_name] + assert token in auth_store.sessions + + body = client.get("/api/v1/me").json() + assert body["role"] == "manager" + assert "/api/v1/team/**" in body["allowed_paths"] + assert "/trade-in/sale-share/**" in body["deny_paths"] + + assert client.post("/api/v1/auth/logout").status_code == 200 + assert token not in auth_store.sessions diff --git a/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py b/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py new file mode 100644 index 00000000..e6df03cc --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py @@ -0,0 +1,469 @@ +"""DSN БД `auth` из частей: один секрет — одно место (эпик «единый вход»). + +Зачем это вообще. Пароль роли `auth_app` уже лежит в `.env.runtime` отдельной +переменной `AUTH_DB_PASSWORD` — её читает `.forgejo/workflows/deploy.yml`, чтобы +сделать `ALTER ROLE`. Требовать вдобавок целиковый `AUTH_DATABASE_URL` с тем же +паролем внутри значило бы держать ОДИН секрет в ДВУХ местах: ротировали пароль +роли, забыли переписать DSN — и вход ложится молча и целиком, у всех сразу. +Поэтому DSN собирается из частей, а явный `AUTH_DATABASE_URL` остаётся +приоритетным аварийным обходом. + +Что пинят тесты ниже: + + 1. Дефолтный режим (`IDENTITY_STORE=tradein`) НЕ требует ни одной новой + переменной — прод после мержа работает ровно как сейчас. + 2. Хост по умолчанию — `gendesign-postgres`, НЕ `postgres`. Внутри стека + «Меры» имя `postgres` резолвится в её собственный контейнер (см. коммент у + констант в `app/core/config.py`), и дефолт `postgres` увёл бы аутентификацию + в продуктовую БД, где нет ни роли, ни таблиц реестра. + 3. Явный `AUTH_DATABASE_URL` бьёт сборку из частей. + 4. Пароль экранируется: спецсимвол внутри него не имеет права порвать URL — + иначе разбор молча уедет на другой хост/базу. + 5. Пароль НЕ попадает ни в текст исключения, ни в traceback — и ни в + `repr(settings)` / `model_dump()` (поле — `SecretStr`). + 6. Ни одна новая переменная не способна уронить СТАРТ процесса: пустое + значение любой части (включая `int`-порт, который валидируется на импорте) + падает обратно на дефолт, а не в ValidationError. + +⚠️ Все «пароли» в этом файле — синтетические строки для проверки экранирования, +не секреты (настоящий живёт только в `.env.runtime` на VPS). +""" + +from __future__ import annotations + +import os +import traceback + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from pydantic import SecretStr +from sqlalchemy.engine.url import make_url + +from app.core import auth_db, config +from tests.support.identity_modes import use_identity_mode + +# Набор символов, каждый из которых имеет СВОЙ смысл в грамматике URL: +# `@` — граница userinfo/host, `:` — граница user/password и host/port, +# `/` — начало пути (имени БД), `?` — начало query, `#` — начало фрагмента, +# `%` — начало процентной последовательности. +_SPECIALS_PASSWORD = "p@ss:w/o?rd#1%" +# Все переменные, которые новый код читает из окружения: в тестах, пинящих +# ДЕФОЛТЫ КОДА, их нужно снести — на дев-машине/CI они могут быть заданы. +_AUTH_ENV_VARS = ( + "AUTH_DATABASE_URL", + "AUTH_DB_PASSWORD", + "AUTH_DB_HOST", + "AUTH_DB_PORT", + "AUTH_DB_NAME", + "AUTH_DB_USER", + "IDENTITY_STORE", +) + + +@pytest.fixture(autouse=True) +def _clean_auth_config(monkeypatch: pytest.MonkeyPatch): + """Чистая конфигурация реестра до и после каждого теста. + + Engine БД `auth` живёт в module-global, а не в `settings`, поэтому + monkeypatch его не откатывает — сбрасываем явно с обеих сторон, иначе + построенный здесь engine утёк бы в соседние тесты сьюта. + """ + auth_db.reset_auth_db() + monkeypatch.setattr(config.settings, "identity_store", "tradein") + monkeypatch.setattr(config.settings, "auth_database_url", "") + monkeypatch.setattr(config.settings, "auth_db_password", SecretStr("")) + yield + auth_db.reset_auth_db() + + +def _fresh_settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> config.Settings: + """Настройки, собранные ЗАНОВО из чистого окружения + *env*. + + `_env_file=None` — не читать локальный `.env` (дев-машина и CI держат там + своё): пиним то, что записано литералом в `Settings`, а не окружение. + """ + for name in _AUTH_ENV_VARS: + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + return config.Settings(_env_file=None) # type: ignore[call-arg] + + +def _set_password(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + """Подменить пароль на ЖИВОМ `settings` (для тестов, идущих через auth_db). + + Обязательно через `SecretStr`: поле объявлено секретным, а `validate_assignment` + у `Settings` выключен — `monkeypatch.setattr` кладёт объект КАК ЕСТЬ, без + приведения типа. Голая строка тихо прошла бы присваивание и упала бы уже в + резолвере на `.get_secret_value()`. + """ + monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(value)) + + +# --------------------------------------------------------------------------- +# Дефолт: новая механика ничего не требует +# --------------------------------------------------------------------------- + + +def test_nothing_configured_means_empty_dsn(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одной переменной — DSN пуст, и это не ошибка. + + Главный инвариант обратной совместимости: прод сегодня живёт с + `IDENTITY_STORE=tradein` и без всяких AUTH_*-переменных. Появление сборки из + частей не имеет права ни сделать что-то обязательным, ни начать угадывать + пароль. + """ + fresh = _fresh_settings(monkeypatch) + + assert fresh.identity_store == "tradein" + assert fresh.auth_db_password.get_secret_value() == "" + assert fresh.resolved_auth_database_url == "", ( + "без AUTH_DATABASE_URL и без AUTH_DB_PASSWORD реестр обязан считаться " + "несконфигурированным — иначе дефолтный режим полез бы в БД `auth`" + ) + + +def test_default_mode_never_builds_engine_even_with_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`AUTH_DB_PASSWORD` сам по себе НЕ включает новый реестр. + + Переменная может приехать в `.env.runtime` заранее (deploy.yml ставит ею + пароль роли задолго до переключения) — это не должно ничего активировать. + Переключатель ровно один: `IDENTITY_STORE`. + """ + _set_password(monkeypatch, _SPECIALS_PASSWORD) + + assert config.settings.identity_store == "tradein" + assert auth_db._engine is None + assert auth_db._session_factory is None + + +# --------------------------------------------------------------------------- +# Сборка из частей +# --------------------------------------------------------------------------- + + +def test_dsn_assembled_from_password_and_prod_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + """Задан только пароль — остальное берётся из прод-дефолтов.""" + fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="parts-only") + + assert ( + fresh.resolved_auth_database_url + == "postgresql+psycopg://auth_app:parts-only@gendesign-postgres:5432/auth" + ) + + +def test_default_host_is_shared_alias_not_own_postgres(monkeypatch: pytest.MonkeyPatch) -> None: + """Хост по умолчанию — алиас чужого стека, а НЕ `postgres`. + + ⚠️ Самая дорогая из возможных ошибок дефолта. `postgres` внутри «Меры» — + это её собственный контейнер (`tradein-mvp/docker-compose.prod.yml` собирает + им продуктовый DATABASE_URL), а БД `auth` живёт на постгресе главного стека, + видном по алиасу `gendesign-postgres` в сети `gendesign_shared` (корневой + `docker-compose.prod.yml`). Подключение по `postgres` не упало бы «не тем» + хостом — оно бы ушло в живую БД, где просто нет ни роли, ни таблиц реестра. + """ + url = make_url(_fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url) + + assert url.host == "gendesign-postgres" + assert url.host != "postgres" + assert url.port == 5432 + assert url.database == "auth" + assert url.username == "auth_app" + + +def test_scheme_matches_product_dsn_psycopg_v3(monkeypatch: pytest.MonkeyPatch) -> None: + """Схема — та же, что у основного DATABASE_URL: psycopg v3. + + `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого нет в + зависимостях (`ModuleNotFoundError` на первом же обращении к реестру). + """ + dsn = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url + + assert dsn.startswith("postgresql+psycopg://") + assert make_url(dsn).drivername == make_url(config.settings.database_url).drivername + + +def test_parts_are_overridable_via_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Каждая часть переопределяется своей переменной (dev / SSH-туннель).""" + fresh = _fresh_settings( + monkeypatch, + AUTH_DB_PASSWORD="tunnel", + AUTH_DB_HOST="localhost", + AUTH_DB_PORT="15432", + AUTH_DB_NAME="auth_copy", + AUTH_DB_USER="reader", + ) + + assert ( + fresh.resolved_auth_database_url + == "postgresql+psycopg://reader:tunnel@localhost:15432/auth_copy" + ) + + +def test_blank_part_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: + """`AUTH_DB_HOST=` (пустая строка в .env) — опечатка, а не «хост пустой». + + Без этого получился бы DSN `...@:5432/auth`, который разберётся и уедет + коннектиться в непредсказуемое место вместо внятной ошибки. + + ПОРТ здесь же и намеренно: он единственный из частей типизирован `int`, и + правило «пусто → дефолт» держится для него отдельным валидатором. Читатель + обоснованно распространяет правило на всю семью AUTH_DB_* — пусть тест это и + подтверждает, а не только host/user. + """ + fresh = _fresh_settings( + monkeypatch, + AUTH_DB_PASSWORD="x", + AUTH_DB_HOST=" ", + AUTH_DB_USER="", + AUTH_DB_PORT="", + AUTH_DB_NAME=" ", + ) + + url = make_url(fresh.resolved_auth_database_url) + assert url.host == "gendesign-postgres" + assert url.username == "auth_app" + assert url.port == 5432 + assert url.database == "auth" + + +def test_blank_port_does_not_break_default_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """`AUTH_DB_PORT=` не имеет права ронять КОНФИГ — тем более в режиме tradein. + + ⚠️ Тут пинится не DSN, а старт процесса. `settings = Settings()` выполняется + на уровне модуля `app/core/config.py`, а `int`-поле валидируется pydantic'ом + ДО всякой логики резолвера: без `_blank_port_means_default` пустая строка + давала бы ValidationError НА ИМПОРТЕ — то есть не отказ auth-пути, а + restart-loop контейнера. И это при `IDENTITY_STORE=tradein`, где новая + механика не должна читаться вообще. + + Сценарий ровно тот, ради которого дефолты и заводились: ops кладёт в + .env.runtime шаблон блока AUTH_DB_*, заполняя только пароль. + """ + fresh = _fresh_settings(monkeypatch, AUTH_DB_PORT="") + + assert fresh.auth_db_port == 5432 + assert fresh.identity_store == "tradein" + assert fresh.resolved_auth_database_url == "" + + +def test_garbage_port_still_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None: + """`AUTH_DB_PORT=abc` обязан падать: это опечатка со смыслом, не «пусто». + + Граница послабления: пустую строку мы прощаем (её оставляют намеренно), + непустой мусор — нет, иначе тихо уехали бы на 5432 мимо того порта, который + человек имел в виду. + """ + with pytest.raises(ValueError): + _fresh_settings(monkeypatch, AUTH_DB_PORT="abc") + + +def test_whitespace_only_password_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: + """Пароль из одних пробелов = не задан (симметрично пустому DSN).""" + assert _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=" ").resolved_auth_database_url == "" + + +# --------------------------------------------------------------------------- +# Приоритет явного DSN +# --------------------------------------------------------------------------- + + +def test_explicit_dsn_wins_over_parts(monkeypatch: pytest.MonkeyPatch) -> None: + """Явный `AUTH_DATABASE_URL` выигрывает — обратная совместимость + обход. + + Кто уже настроил стек по-старому, не должен ничего менять; и остаётся + аварийный путь вписать нестандартный DSN (другой хост, `sslmode`, пул-байпас) + без правки кода. + """ + explicit = "postgresql+psycopg://other:whole-dsn@elsewhere:6432/auth?sslmode=require" + fresh = _fresh_settings( + monkeypatch, + AUTH_DATABASE_URL=explicit, + AUTH_DB_PASSWORD="parts-must-lose", + AUTH_DB_HOST="ignored-host", + ) + + assert fresh.resolved_auth_database_url == explicit + + +def test_explicit_dsn_is_stripped_and_blank_falls_through_to_parts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """DSN из одних пробелов = не задан → сборка из частей, а не пустота. + + Иначе `AUTH_DATABASE_URL=` (оставленная в файле пустая строка) заблокировала + бы работающий пароль и дала бы отказ входа на пустом месте. + """ + fresh = _fresh_settings(monkeypatch, AUTH_DATABASE_URL=" ", AUTH_DB_PASSWORD="fallback") + + assert make_url(fresh.resolved_auth_database_url).password == "fallback" + + +# --------------------------------------------------------------------------- +# Экранирование +# --------------------------------------------------------------------------- + + +def test_special_chars_in_password_survive_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None: + """`@ : / ? # %` в пароле не рвут URL — разбор возвращает его дословно. + + Каждый из этих символов — разделитель в грамматике URL. Без экранирования + `@` сдвинул бы границу хоста, `/` открыл бы имя БД, `%` дал бы битую + процентную последовательность. Проверка — round-trip через тот же парсер, + которым пользуется SQLAlchemy при создании engine. + """ + fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD) + dsn = fresh.resolved_auth_database_url + + assert "p%40ss%3Aw%2Fo%3Frd%231%25" in dsn, "пароль обязан быть percent-encoded" + assert _SPECIALS_PASSWORD not in dsn, "сырой пароль в DSN = незаэкранированные разделители" + + url = make_url(dsn) + assert url.password == _SPECIALS_PASSWORD + # И, главное, разделители из пароля не увели разбор в другое место: + assert (url.username, url.host, url.port, url.database) == ( + "auth_app", + "gendesign-postgres", + 5432, + "auth", + ) + + +def test_special_chars_in_user_are_escaped(monkeypatch: pytest.MonkeyPatch) -> None: + """Имя пользователя экранируется по той же причине, что и пароль. + + `@` в имени роли иначе сдвинул бы границу userinfo/host — и коннект молча + пошёл бы не туда. + """ + fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_USER="a@b") + + url = make_url(fresh.resolved_auth_database_url) + assert url.username == "a@b" + assert url.host == "gendesign-postgres" + + +def test_dbname_is_passed_through_unescaped(monkeypatch: pytest.MonkeyPatch) -> None: + """Имя БД НЕ percent-энкодится — иначе в сервер уедет литеральное `%2F`. + + Асимметрия не случайна и легко читается как баг: SQLAlchemy раскодирует + обратно только userinfo (user/password), а path отдаёт как есть. Пропусти мы + имя БД через `quote`, `c/d` превратилось бы в `c%2Fd` уже НА СТОРОНЕ + ПОСТГРЕСА (`database "c%2Fd" does not exist`). Тест пинит именно round-trip. + """ + fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_NAME="c/d") + + assert make_url(fresh.resolved_auth_database_url).database == "c/d" + + +def test_engine_from_parts_carries_exact_password(monkeypatch: pytest.MonkeyPatch) -> None: + """Сквозная проверка: engine строится из частей и несёт ИМЕННО тот пароль. + + `create_engine` к серверу не ходит (пул ленивый), поэтому живая БД не нужна — + но URL внутри engine уже разобран SQLAlchemy, то есть это проверка всей + цепочки «части → экранирование → разбор», а не только строки. + """ + use_identity_mode(monkeypatch, "auth") + _set_password(monkeypatch, _SPECIALS_PASSWORD) + + engine = auth_db.get_auth_engine() + + assert engine.url.password == _SPECIALS_PASSWORD + assert engine.url.host == "gendesign-postgres" + assert engine.url.database == "auth" + # repr URL маскирует пароль — на этом держится безопасность чужих логов. + assert _SPECIALS_PASSWORD not in repr(engine.url) + + +# --------------------------------------------------------------------------- +# Ошибки: явные, но без секрета внутри +# --------------------------------------------------------------------------- + + +def test_auth_mode_without_password_and_dsn_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Режим `auth` без конфигурации — явная ошибка, как и до появления частей. + + Тихий фолбэк был бы худшим исходом: вход «работал» бы по неактуальному + реестру либо молча отказывал бы всем под видом неверных паролей. + """ + use_identity_mode(monkeypatch, "auth") + + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo: + auth_db.get_auth_engine() + + message = str(excinfo.value) + # Текст обязан называть ОБА пути конфигурации — иначе дежурный будет искать + # переменную, которую мы же и перестали требовать. + assert "AUTH_DB_PASSWORD" in message + assert "AUTH_DATABASE_URL" in message + assert "IDENTITY_STORE=tradein" in message + + +def test_malformed_explicit_dsn_never_leaks_password(monkeypatch: pytest.MonkeyPatch) -> None: + """Нечитаемый DSN → своя ошибка; ни пароля, ни его обломков нигде. + + Ловушка, ради которой существует `from None`: на «почти URL» разбор + SQLAlchemy доходит до `int(port)` и падает с `invalid literal for int() with + base 10: 'w'`, где `'w'` — символ ПАРОЛЯ, съехавший на позицию порта. Без + обрыва цепочки исключений он всплыл бы в traceback («During handling of the + above exception...») — то есть в логи и в GlitchTip. + """ + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr( + config.settings, + "auth_database_url", + f"garbage://auth_app:{_SPECIALS_PASSWORD}@gendesign-postgres/auth", + ) + + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo: + auth_db.get_auth_engine() + + exc = excinfo.value + rendered = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + + assert _SPECIALS_PASSWORD not in rendered + # Обломки пароля тоже не должны просочиться: пиним, что цепочка оборвана и + # рендерится ровно наше сообщение-константа. + assert "invalid literal for int" not in rendered + assert exc.__cause__ is None + assert exc.__suppress_context__ is True + assert str(exc) == auth_db._MALFORMED_DSN_MSG + + +def test_assembled_dsn_is_never_malformed(monkeypatch: pytest.MonkeyPatch) -> None: + """Сборка из частей не может дать нечитаемый DSN даже на злом пароле. + + Обратная сторона экранирования: путь «из частей» не должен уметь попадать в + ветку `_MALFORMED_DSN_MSG` вообще — иначе ротация пароля с неудачным + символом положила бы вход. + """ + use_identity_mode(monkeypatch, "auth") + _set_password(monkeypatch, "://@:/?#%" + _SPECIALS_PASSWORD) + + engine = auth_db.get_auth_engine() + + assert engine.url.password == "://@:/?#%" + _SPECIALS_PASSWORD + assert engine.url.host == "gendesign-postgres" + + +def test_password_is_not_rendered_by_settings_repr_or_dump( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пароль не печатается ни в `repr(settings)`, ни в `model_dump()`. + + Канал утечки, которого не видно глазами: обычное `str`-поле pydantic + выводит ДОСЛОВНО, поэтому любой будущий дамп настроек (диагностический + роут, `logger.debug("%s", settings)`, чужой обработчик ошибок) утащил бы + пароль роли `auth_app` в логи целиком. Поле объявлено `SecretStr` — здесь + пинится именно это свойство, а не факт наличия обёртки, чтобы откат к + голому `str` красил тест. + """ + fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD) + + assert _SPECIALS_PASSWORD not in repr(fresh) + assert _SPECIALS_PASSWORD not in str(fresh.model_dump()) + # …и при этом значение достаётся: маскировка не должна ломать работу. + assert fresh.auth_db_password.get_secret_value() == _SPECIALS_PASSWORD + assert make_url(fresh.resolved_auth_database_url).password == _SPECIALS_PASSWORD diff --git a/tradein-mvp/backend/tests/test_auth_session.py b/tradein-mvp/backend/tests/test_auth_session.py new file mode 100644 index 00000000..4595b6b9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_session.py @@ -0,0 +1,439 @@ +"""Tests for app.services.auth_session — session CRUD + DB-role scope mapping (#2552). + +Coverage: + - create_session: INSERT with CAST(...) (never `:x::type`), commit, unique tokens. + - get_session_user: valid/expired/не-active/missing-row + sliding refresh (only when + last_seen_at is stale, best-effort — a refresh failure still returns the user). + - get_user_by_username: found/not-found + состояние доступа как `AccessState`. + - revoke_session / revoke_user_sessions: DELETE + commit. + - get_db_role_scope: employee/manager/admin/unknown mapping. + +All functions here take `db: Session` as a plain argument (no SessionLocal() opened +internally) — unit tests just pass a hand-rolled fake, mirroring the `_FakeSession` +pattern from tests/test_user_events.py but adapted for `.fetchone()`-based reads. + +⚠️ ОБА РЕЖИМА РЕЕСТРА. Эпик «единый вход» вынес имена таблиц и имя/тип колонки +состояния доступа в `identity_store.identity_schema()`. Тесты, которые вообще +трогают SQL, прогоняются в ОБОИХ режимах (фикстура `identity_mode`): "tradein" +(дефолт, сегодняшний прод — `tradein_users`/`tradein_sessions`, boolean +`is_active`) и "auth" (`users`/`sessions`, text `access_state`). Ожидаемые имена +в ассертах берутся из `identity_schema()` — из того же словаря, что и у кода, +поэтому переименование таблиц не «разъезжает» тест с реальностью тихо; +поломка запроса ловится тем, что fake отдаёт строку ТОЛЬКО на ожидаемый SQL, +а сам SQL проверяется явными ассертами ниже. + +Тесты БЕЗ фикстуры `identity_mode` намеренно идут в дефолтном режиме +(`_default_identity_mode` autouse) — это чистая логика без SQL. +""" + +from __future__ import annotations + +import os +import re +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.services import auth_session as svc +from app.services.identity_store import AccessState, identity_schema +from tests.support.identity_modes import IDENTITY_MODES, column_value, use_identity_mode + +# --------------------------------------------------------------------------- +# Режим реестра +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _default_identity_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Каждый тест стартует в ДЕФОЛТНОМ режиме, даже если предыдущий его менял.""" + use_identity_mode(monkeypatch, "tradein") + + +@pytest.fixture(params=IDENTITY_MODES) +def identity_mode(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str: + """Тест прогоняется дважды: "tradein" (прод) и "auth" (после переезда).""" + return use_identity_mode(monkeypatch, request.param) + + +@pytest.fixture +def auth_mode(monkeypatch: pytest.MonkeyPatch) -> str: + """Только режим "auth" — для состояний, невыразимых булевой колонкой.""" + return use_identity_mode(monkeypatch, "auth") + + +# --------------------------------------------------------------------------- +# Fake DB session +# --------------------------------------------------------------------------- + + +class _FakeDB: + """Minimal `Session` stand-in: queued `.fetchone()` results per `execute()` call, + in call order. `execute()` beyond the queue returns a result with `fetchone() + -> None`. Set `.raise_on_call = N` to make the Nth `execute()` (1-indexed) raise. + """ + + def __init__(self, rows: list[Any] | None = None) -> None: + self._rows: list[Any] = list(rows or []) + self.executed: list[tuple[str, dict[str, Any] | None]] = [] + self.committed = 0 + self.rolled_back = 0 + self.raise_on_call: int | None = None + + def execute(self, stmt: object, params: dict[str, Any] | None = None) -> SimpleNamespace: + call_no = len(self.executed) + 1 + self.executed.append((str(stmt), params)) + if self.raise_on_call == call_no: + raise RuntimeError("simulated DB failure") + row = self._rows.pop(0) if self._rows else None + return SimpleNamespace(fetchone=lambda: row) + + def commit(self) -> None: + self.committed += 1 + + def rollback(self) -> None: + self.rolled_back += 1 + + +# Часовой «аргумент не передан» — None здесь занят (это валидное сырое значение +# колонки: NULL, который to_access_state обязан трактовать как disabled). +_MISSING = object() + + +def _session_row( + *, + user_id: int = 1, + expires_at: datetime | None = None, + last_seen_at: datetime | None = None, + username: str = "alice", + role: str = "employee", + access_state: AccessState = AccessState.ACTIVE, + raw_access_state: object = _MISSING, +) -> SimpleNamespace: + """Строка JOIN'а sessions×users, как её отдал бы драйвер. + + Колонка состояния всегда приезжает под алиасом `access_state` (`AS access_state` + в реальном SELECT'е), а ЗНАЧЕНИЕ в ней — то, что лежит в БД текущего режима: + boolean для `tradein_users.is_active`, text для `auth.users.access_state`. + *raw_access_state* — обход таблицы состояний для проверки fail-closed на + значении, которого код не знает. + """ + now = datetime.now(UTC) + return SimpleNamespace( + user_id=user_id, + expires_at=expires_at if expires_at is not None else now + timedelta(hours=1), + last_seen_at=last_seen_at if last_seen_at is not None else now, + username=username, + role=role, + display_name="Alice A.", + org_name="Org LLC", + email="alice@example.com", + access_state=( + column_value(access_state) if raw_access_state is _MISSING else raw_access_state + ), + ) + + +def _user_row( + *, + user_id: int = 1, + username: str = "alice", + password_hash: str | None = "hash", + role: str = "employee", + access_state: AccessState = AccessState.ACTIVE, +) -> SimpleNamespace: + return SimpleNamespace( + id=user_id, + username=username, + password_hash=password_hash, + role=role, + access_state=column_value(access_state), + display_name="Alice A.", + org_name="Org LLC", + email="alice@example.com", + ) + + +# --------------------------------------------------------------------------- +# create_session +# --------------------------------------------------------------------------- + + +def test_create_session_inserts_and_commits(identity_mode: str) -> None: + db = _FakeDB() + token = svc.create_session(db, user_id=42, ip="1.2.3.4", user_agent="pytest") + + assert db.committed == 1 + assert len(db.executed) == 1 + sql, params = db.executed[0] + assert f"INSERT INTO {identity_schema().sessions_table}" in sql + assert params is not None + assert params["user_id"] == 42 + assert params["ip"] == "1.2.3.4" + assert params["user_agent"] == "pytest" + assert params["token"] == token + assert isinstance(token, str) + assert len(token) >= 32 + + +def test_create_session_cast_not_doublecolon(identity_mode: str) -> None: + db = _FakeDB() + svc.create_session(db, user_id=1) + sql, _ = db.executed[0] + assert not re.search(r":\w+::\w", sql) + assert "CAST(:ttl_hours AS integer)" in sql + assert "CAST(:ip AS inet)" in sql + + +def test_create_session_tokens_are_unique() -> None: + db = _FakeDB() + t1 = svc.create_session(db, user_id=1) + t2 = svc.create_session(db, user_id=1) + assert t1 != t2 + + +# --------------------------------------------------------------------------- +# get_session_user +# --------------------------------------------------------------------------- + + +def test_get_session_user_no_token_returns_none() -> None: + db = _FakeDB() + assert svc.get_session_user(db, "") is None + assert db.executed == [] + + +def test_get_session_user_missing_row_returns_none(identity_mode: str) -> None: + schema = identity_schema() + db = _FakeDB(rows=[None]) + assert svc.get_session_user(db, "tok") is None + sql, params = db.executed[0] + assert f"FROM {schema.sessions_table} s" in sql + assert f"JOIN {schema.users_table} u" in sql + # Колонка состояния — под именем текущей схемы и обязательно с алиасом: + # без него вызывающий код читал бы то `is_active`, то `access_state`. + assert f"u.{schema.access_state_column} AS access_state" in sql + assert params == {"token": "tok"} + + +def test_get_session_user_expired_returns_none(identity_mode: str) -> None: + now = datetime.now(UTC) + db = _FakeDB(rows=[_session_row(expires_at=now - timedelta(minutes=1))]) + assert svc.get_session_user(db, "tok") is None + # Никакого sliding-refresh UPDATE для невалидной сессии. + assert len(db.executed) == 1 + + +def test_get_session_user_disabled_returns_none(identity_mode: str) -> None: + """Жёстко заблокированный аккаунт — сессия недействительна в обеих схемах.""" + db = _FakeDB(rows=[_session_row(access_state=AccessState.DISABLED)]) + assert svc.get_session_user(db, "tok") is None + assert len(db.executed) == 1 + + +def test_get_session_user_trial_expired_returns_none(auth_mode: str) -> None: + """Пробный период истёк — УЖЕ ВЫДАННАЯ сессия гасится немедленно. + + Иначе сотрудник, залогиненный до истечения пробного доступа, продолжал бы + работать, а sliding-refresh продлевал бы ему `expires_at` бесконечно — + состояние `trial_expired` не наступило бы для него никогда. + """ + db = _FakeDB(rows=[_session_row(access_state=AccessState.TRIAL_EXPIRED)]) + assert svc.get_session_user(db, "tok") is None + # Ни UPDATE (sliding refresh), ни commit — сессия не продлевается. + assert len(db.executed) == 1 + assert db.committed == 0 + + +def test_get_session_user_unknown_state_returns_none(auth_mode: str) -> None: + """Fail-closed: состояние, которого код не знает (миграция впереди кода), + НЕ пускает. Обратный выбор молча раздавал бы доступ по новому значению.""" + db = _FakeDB(rows=[_session_row(raw_access_state="pending_review")]) + assert svc.get_session_user(db, "tok") is None + assert len(db.executed) == 1 + + +def test_get_session_user_valid_recent_no_refresh(identity_mode: str) -> None: + """last_seen_at свежий (<5 мин) — sliding refresh НЕ триггерится.""" + now = datetime.now(UTC) + db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=1))]) + result = svc.get_session_user(db, "tok") + + assert result is not None + assert result["username"] == "alice" + assert result["role"] == "employee" + assert result["display_name"] == "Alice A." + assert result["org_name"] == "Org LLC" + assert result["email"] == "alice@example.com" + assert result["user_id"] == 1 + # Состояние доступа приезжает ЕДИНЫМ понятием, а не boolean/str по режимам; + # сюда доходит только ACTIVE (не-active отсеян выше). + assert result["access_state"] is AccessState.ACTIVE + # Только 1 execute (SELECT) — никакого UPDATE. + assert len(db.executed) == 1 + assert db.committed == 0 + + +def test_get_session_user_stale_last_seen_triggers_refresh(identity_mode: str) -> None: + """last_seen_at старше 5 минут — один UPDATE (sliding refresh) + commit.""" + now = datetime.now(UTC) + db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))]) + result = svc.get_session_user(db, "tok") + + assert result is not None + assert len(db.executed) == 2 + update_sql, update_params = db.executed[1] + assert f"UPDATE {identity_schema().sessions_table}" in update_sql + assert "SET last_seen_at" in update_sql + assert not re.search(r":\w+::\w", update_sql) + assert "CAST(:ttl_hours AS integer)" in update_sql + assert update_params == {"ttl_hours": 720, "token": "tok"} + assert db.committed == 1 + + +def test_get_session_user_refresh_failure_is_swallowed(identity_mode: str) -> None: + """Sliding-refresh UPDATE падает — всё равно возвращаем валидного юзера + (best-effort refresh, не часть решения "валидна ли сессия").""" + now = datetime.now(UTC) + db = _FakeDB(rows=[_session_row(last_seen_at=now - timedelta(minutes=10))]) + db.raise_on_call = 2 + + result = svc.get_session_user(db, "tok") + + assert result is not None + assert result["username"] == "alice" + assert db.rolled_back == 1 + assert db.committed == 0 + + +# --------------------------------------------------------------------------- +# get_user_by_username +# --------------------------------------------------------------------------- + + +def test_get_user_by_username_found(identity_mode: str) -> None: + schema = identity_schema() + db = _FakeDB(rows=[_user_row()]) + user = svc.get_user_by_username(db, "alice") + + assert user is not None + assert user["username"] == "alice" + assert user["password_hash"] == "hash" + assert user["role"] == "employee" + assert user["access_state"] is AccessState.ACTIVE + sql, params = db.executed[0] + assert f"FROM {schema.users_table}" in sql + assert f"{schema.access_state_column} AS access_state" in sql + assert params == {"username": "alice"} + + +def test_get_user_by_username_disabled_state_is_reported_not_hidden(identity_mode: str) -> None: + """Строка отдаётся ВСЕГДА, состояние — отдельным полем. + + Login обязан отличать «нет такого логина» (None) от «есть, но доступ закрыт» + (строка + не-ACTIVE): от этого зависит выбор события аудита, а прятать + заблокированного за None означало бы потерять эту разницу. + """ + db = _FakeDB(rows=[_user_row(access_state=AccessState.DISABLED)]) + user = svc.get_user_by_username(db, "alice") + + assert user is not None + assert user["access_state"] is AccessState.DISABLED + assert user["access_state"].can_sign_in is False + + +def test_get_user_by_username_trial_expired_state(auth_mode: str) -> None: + db = _FakeDB(rows=[_user_row(access_state=AccessState.TRIAL_EXPIRED)]) + user = svc.get_user_by_username(db, "alice") + + assert user is not None + assert user["access_state"] is AccessState.TRIAL_EXPIRED + assert user["access_state"].can_sign_in is False + + +def test_get_user_by_username_not_found(identity_mode: str) -> None: + db = _FakeDB(rows=[None]) + assert svc.get_user_by_username(db, "ghost") is None + + +# --------------------------------------------------------------------------- +# revoke_session / revoke_user_sessions +# --------------------------------------------------------------------------- + + +def test_revoke_session_deletes_and_commits(identity_mode: str) -> None: + db = _FakeDB() + svc.revoke_session(db, "tok") + + assert db.committed == 1 + sql, params = db.executed[0] + assert f"DELETE FROM {identity_schema().sessions_table}" in sql + assert "token" in sql + assert params == {"token": "tok"} + + +def test_revoke_user_sessions_deletes_and_commits(identity_mode: str) -> None: + db = _FakeDB() + svc.revoke_user_sessions(db, 7) + + assert db.committed == 1 + sql, params = db.executed[0] + assert f"DELETE FROM {identity_schema().sessions_table}" in sql + assert "user_id" in sql + assert params == {"user_id": 7} + + +# --------------------------------------------------------------------------- +# get_db_role_scope +# --------------------------------------------------------------------------- + + +def test_get_db_role_scope_employee_matches_legacy_pilot() -> None: + paths, deny = svc.get_db_role_scope("employee") + assert "/trade-in/**" in paths + assert "/trade-in/api/v1/**" in paths + assert "/admin/**" in deny + assert "/api/v1/admin/**" in deny + + +def test_get_db_role_scope_manager_adds_team_path() -> None: + paths, deny = svc.get_db_role_scope("manager") + assert "/trade-in/**" in paths + assert "/api/v1/team/**" in paths + assert "/admin/**" in deny + + +# «Доля в продаже» и «Кэш» — внутренние инструменты (аналитика рынка / состояние +# кэшей и скраперов), клиентские роли их не видят (решение владельца 2026-07-31). +# В deny И страницы, И их API: один список гейтит пункт меню (Topbar через /me), +# страницу (RouteGuard) и серверные ручки (rbac_guard). +_INTERNAL_TOOL_DENY = ( + "/trade-in/sale-share/**", + "/trade-in/cache/**", + "/trade-in/api/v1/buildings/**", + # Глоб, а не точный путь: точный обходится трейлинг-слэшем (см. NB в + # app.services.auth_session над DB_ROLE_PATHS). + "/trade-in/api/v1/trade-in/cache-stats/**", +) + + +def test_get_db_role_scope_client_roles_deny_internal_tools() -> None: + for role in ("manager", "employee"): + _, deny = svc.get_db_role_scope(role) + for pattern in _INTERNAL_TOOL_DENY: + assert pattern in deny, f"{role} deny missing {pattern}" + + +def test_get_db_role_scope_admin_full_access() -> None: + paths, deny = svc.get_db_role_scope("admin") + assert paths == ["/**"] + assert deny == [] + + +def test_get_db_role_scope_unknown_role_denies_all() -> None: + paths, deny = svc.get_db_role_scope("ghost") + assert paths == [] + assert deny == ["/**"] diff --git a/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py b/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py index f28ed174..57e494d9 100644 --- a/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py +++ b/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py @@ -20,6 +20,7 @@ Refs: audit-scrapers 2026-07-26, finding 1 (medium). from __future__ import annotations import os +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -69,6 +70,35 @@ async def test_citywide_page1_zero_cards_no_marker_raises() -> None: await s.fetch_city_wide(pages=5, delay_override_sec=0) +@pytest.mark.asyncio +async def test_citywide_page1_zero_cards_reports_ban_when_browser_lease_active() -> None: + """#2600 п.1: AvitoContentBlockedError → self._report_ban → browser.report_ban, + ПОКА self._browser (lease) ещё жив (`__aexit__` не вызывался, self._browser + установлен напрямую — тот же паттерн, что `orchestration/pipeline.py:: + run_avito_pipeline` own_browser-путь, минующий AvitoScraper.__aenter__).""" + s = AvitoScraper(RealScraperConfig()) + banned: list[str] = [] + s._browser = SimpleNamespace(report_ban=lambda reason: banned.append(reason)) # type: ignore[assignment] + + with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)): + with pytest.raises(AvitoContentBlockedError): + await s.fetch_city_wide(pages=5, delay_override_sec=0) + + assert banned # report_ban вызван на живом lease, ДО того как исключение всплыло + + +@pytest.mark.asyncio +async def test_citywide_page1_zero_cards_no_browser_does_not_crash() -> None: + """cffi-only режим (self._browser=None, дефолт в этих тестах) — _report_ban + no-op, исключение по-прежнему поднимается штатно (parity с уже существующим + test_citywide_page1_zero_cards_no_marker_raises).""" + s = AvitoScraper(RealScraperConfig()) + assert s._browser is None + with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)): + with pytest.raises(AvitoContentBlockedError): + await s.fetch_city_wide(pages=5, delay_override_sec=0) + + @pytest.mark.asyncio async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None: s = AvitoScraper(RealScraperConfig()) diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py new file mode 100644 index 00000000..327e32ac --- /dev/null +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -0,0 +1,82 @@ +"""#2674 — detail-backfill с нулём обогащений перестаёт называться 'done'. + +Все три backfill'а (avito/yandex/domclick) финализировались одним mark_done. +На проде 2026-08-06 это 78 прогонов из 158: avito 23/76 (включая 5 прогонов на +1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 +(494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). + +Проверяем ровно ветвление mark_backfill_finished — БД замокана. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import scrape_runs as runs_mod + + +def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, str | None]: + """Вызвать mark_backfill_finished с замоканными mark_* → (статус, причина).""" + calls: list[tuple[str, str | None]] = [] + with ( + patch.object(runs_mod, "mark_done", lambda *a, **k: calls.append(("done", None))), + patch.object( + runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err)) + ), + patch.object( + runs_mod, "mark_banned", lambda db, rid, err, c: calls.append(("banned", err)) + ), + ): + runs_mod.mark_backfill_finished( + MagicMock(), + 1, + counters, + source="domclick_detail_backfill", + aborted_by_blocks=aborted, + ) + assert len(calls) == 1, f"ожидался ровно один финализатор, получено {calls}" + return calls[0] + + +@pytest.mark.parametrize( + ("counters", "aborted", "expected"), + [ + # Прод-факт domclick: 16 прогонов attempted=3 blocked=3 → брейкер оборвал. + ({"attempted": 3, "enriched": 0, "blocked": 3, "failed": 0}, True, "banned"), + # Прод-факт domclick: 100 попыток, все fail, блоки не распознаны (до #2645). + ({"attempted": 100, "enriched": 0, "blocked": 0, "failed": 100}, False, "failed"), + # Прод-факт avito: 1500 попыток, 1499 fail + 1 блок, ноль обогащений. + ({"attempted": 1500, "enriched": 0, "blocked": 1, "failed": 1499}, False, "banned"), + # Прод-факт yandex: 31 прогон attempted=5 failed=5, ключа blocked нет вовсе. + ({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"), + # Кандидатов не было — честная пустота, это успех. + ({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"), + # Частичный прогон: обогатили хоть что-то → успех. + ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), + # Блоки были, но прогон доработал и обогатил — не бан. + ({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"), + # Блок оборвал прогон, хотя часть успели обогатить — работа не доделана. + ({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"), + # avito: 404-«снято с продажи» — тоже результат, а не пустой прогон. + ({"attempted": 30, "enriched": 0, "gone": 30, "blocked": 0, "failed": 0}, False, "done"), + ], +) +def test_status_matches_reality(counters: dict[str, Any], aborted: bool, expected: str) -> None: + status, _ = _finish(counters, aborted=aborted) + assert status == expected + + +def test_reason_carries_numbers_and_marker() -> None: + """Причина в scrape_runs.error должна быть читаемой человеком, не пустой.""" + status, reason = _finish({"attempted": 3, "enriched": 0, "blocked": 3}, aborted=True) + assert status == "banned" + assert reason is not None + assert "backfill-honest-status" in reason + assert "domclick_detail_backfill" in reason + assert "blocked=3" in reason and "из 3 попыток" in reason diff --git a/tradein-mvp/backend/tests/test_backfill_house_coords.py b/tradein-mvp/backend/tests/test_backfill_house_coords.py deleted file mode 100644 index 2b8da977..00000000 --- a/tradein-mvp/backend/tests/test_backfill_house_coords.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Unit tests for the Phase 2-3 backfill/audit script (issue #582). - -Coverage: - - `forward_via_api` request shape — verifies geocode/format/locality bias. - - `_parse_api_payload` precision + kind extraction. - - `_classify_backfill_status` precision filter rules. - - `_update_house_coords` — UPDATE shape + raw_payload merge. - - `_run_backfill_mode` — happy path UPDATE + audit row, plus imprecise-skip. - - `_run_audit_mode` — ok / mismatch / no_match distinction. - - `main()` resumability — second pass on same batch inserts 0. - -No real Postgres in unit tests (same convention as test_audit_address_mismatch). -DB is a MagicMock that records INSERT/UPDATE calls and routes SELECT side-effects. -""" - -from __future__ import annotations - -import json -import os -from unittest.mock import AsyncMock, MagicMock, patch - -# Same dance as test_audit_address_mismatch — settings needs a DSN at import. -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") - -import httpx -import pytest - -from scripts._yandex_reverse import ( - YandexReverseResult, - _parse_api_payload, - forward_via_api, -) -from scripts.backfill_house_coords import ( - HouseRow, - _classify_backfill_status, - _run_audit_mode, - _run_backfill_mode, - _update_house_coords, - main, -) - -# --------------------------------------------------------------------------- -# forward_via_api — request shape -# --------------------------------------------------------------------------- - - -async def test_forward_api_request_shape(): - """Verify the GET param dict — address as `geocode`, kind=house, EKB bias.""" - fixture = { - "response": { - "GeoObjectCollection": { - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "text": "Россия, Свердловская область, Екатеринбург, " - "улица Малышева, 51", - "precision": "exact", - "kind": "house", - } - }, - "name": "улица Малышева, 51", - "Point": {"pos": "60.586155 56.838004"}, - } - } - ] - } - } - } - captured: dict[str, httpx.Request] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["req"] = request - return httpx.Response(200, json=fixture) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - res = await forward_via_api("ул Малышева 51", "DUMMY_KEY", client=client) - - assert res.address is not None and "Малышева" in res.address - assert res.precision == "exact" - assert res.kind == "house" - assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6) - assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6) - - qs = dict(httpx.QueryParams(captured["req"].url.query)) - assert qs["apikey"] == "DUMMY_KEY" - assert qs["geocode"] == "ул Малышева 51" - assert qs["format"] == "json" - assert qs["kind"] == "house" - # EKB locality bias for forward geocode — important so addresses without - # the city resolve to the correct Малышева (there's one in Moscow too). - assert "ll" in qs - assert "spn" in qs - - -# --------------------------------------------------------------------------- -# Precision / kind passthrough in _parse_api_payload -# --------------------------------------------------------------------------- - - -def test_parse_api_payload_propagates_precision_and_kind(): - data = { - "response": { - "GeoObjectCollection": { - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "text": "ул Ленина 5", - "precision": "exact", - "kind": "house", - } - }, - "name": "ул Ленина 5", - "Point": {"pos": "60.6 56.8"}, - } - } - ] - } - } - } - res = _parse_api_payload(data) - assert res.precision == "exact" - assert res.kind == "house" - - -# --------------------------------------------------------------------------- -# _classify_backfill_status — precision filter rules -# --------------------------------------------------------------------------- - - -def test_classify_backfill_status_exact_match(): - res = YandexReverseResult( - address="ул Малышева 51", - snapped_lat=56.838, - snapped_lon=60.586, - precision="exact", - kind="house", - ) - assert _classify_backfill_status(res) == "backfill" - - -def test_classify_backfill_status_number_match(): - res = YandexReverseResult( - address="ул Ленина 5", - snapped_lat=56.840, - snapped_lon=60.600, - precision="number", - kind="house", - ) - assert _classify_backfill_status(res) == "backfill" - - -def test_classify_backfill_status_street_is_imprecise(): - res = YandexReverseResult( - address="ул Ленина", - snapped_lat=56.840, - snapped_lon=60.600, - precision="street", - kind="street", - ) - assert _classify_backfill_status(res) == "imprecise" - - -def test_classify_backfill_status_other_is_imprecise(): - res = YandexReverseResult( - address="Свердловская область", - snapped_lat=56.8, - snapped_lon=60.6, - precision="other", - kind="locality", - ) - assert _classify_backfill_status(res) == "imprecise" - - -def test_classify_backfill_status_no_match(): - res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None) - assert _classify_backfill_status(res) == "no_match" - - -def test_classify_backfill_status_none(): - assert _classify_backfill_status(None) == "no_match" - - -def test_classify_backfill_status_precision_ok_but_no_coords(): - """Defensive: precision=exact but snapped point missing → no_match, not backfill.""" - res = YandexReverseResult( - address="ул Малышева 51", - snapped_lat=None, - snapped_lon=None, - precision="exact", - kind="house", - ) - assert _classify_backfill_status(res) == "no_match" - - -# --------------------------------------------------------------------------- -# _update_house_coords — UPDATE shape verification -# --------------------------------------------------------------------------- - - -def test_update_house_coords_passes_bindings(): - db = MagicMock() - _update_house_coords( - db, - house_id=42, - lat=56.838, - lon=60.586, - payload={"address": "ул Малышева 51", "precision": "exact"}, - ) - args, _kw = db.execute.call_args - sql_str = str(args[0]) - binds = args[1] - assert "UPDATE houses" in sql_str - assert "raw_payload" in sql_str - assert "yandex_geocode" in sql_str - assert binds["id"] == 42 - assert binds["lat"] == 56.838 - assert binds["lon"] == 60.586 - # payload bound as JSON string for CAST(:payload AS jsonb) - decoded = json.loads(binds["payload"]) - assert decoded["address"] == "ул Малышева 51" - - -# --------------------------------------------------------------------------- -# DB mock helper — same approach as test_audit_address_mismatch -# --------------------------------------------------------------------------- - - -def _make_db_mock( - backfill_sample: list[dict] | None = None, - audit_sample: list[dict] | None = None, - processed_ids: set[int] | None = None, - distance_value: float = 12.5, -): - """MagicMock DB that: - - returns `backfill_sample` for `lat IS NULL OR lon IS NULL` SELECT - - returns `audit_sample` for `lat IS NOT NULL` SELECT - - returns `processed_ids` for the resume SELECT - - records INSERTs and UPDATEs - - returns `distance_value` for ST_Distance calls - """ - backfill_sample = backfill_sample or [] - audit_sample = audit_sample or [] - processed_ids = processed_ids if processed_ids is not None else set() - - inserted: list[dict] = [] - updated: list[dict] = [] - - db = MagicMock() - db.begin_nested.return_value.__enter__ = lambda self: self - db.begin_nested.return_value.__exit__ = lambda self, *a: False - - def execute_side_effect(sql, params=None): - sql_str = str(sql) - result = MagicMock() - if "FROM houses" in sql_str and "lat IS NULL OR lon IS NULL" in sql_str: - result.mappings.return_value.all.return_value = backfill_sample - elif "FROM houses" in sql_str and "lat IS NOT NULL" in sql_str: - result.mappings.return_value.all.return_value = audit_sample - elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str: - result.all.return_value = [(hid,) for hid in processed_ids] - elif "INSERT INTO address_mismatch_audit" in sql_str: - inserted.append(dict(params)) - processed_ids.add(params["house_id"]) - elif "UPDATE houses" in sql_str: - updated.append(dict(params)) - elif "ST_Distance" in sql_str: - result.first.return_value = (distance_value,) - return result - - db.execute.side_effect = execute_side_effect - db.commit = MagicMock() - db.rollback = MagicMock() - db.close = MagicMock() - return db, inserted, updated - - -# --------------------------------------------------------------------------- -# _run_backfill_mode — happy path + imprecise-skip -# --------------------------------------------------------------------------- - - -async def test_run_backfill_mode_writes_update_and_audit(): - sample = [ - HouseRow(id=1, address="ул Малышева 51", lat=None, lon=None), - ] - db, inserted, updated = _make_db_mock() - res = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838, - snapped_lon=60.586, - precision="exact", - kind="house", - raw={"ok": True}, - ) - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_backfill_mode(db, sample, "b1", "KEY") - assert n == 1 - assert len(updated) == 1 - assert updated[0]["id"] == 1 - assert updated[0]["lat"] == 56.838 - assert updated[0]["lon"] == 60.586 - assert len(inserted) == 1 - assert inserted[0]["audit_status"] == "backfill" - assert inserted[0]["snapped_address"] == "Россия, Екатеринбург, улица Малышева, 51" - - -async def test_run_backfill_mode_imprecise_skips_update(): - """precision='street' → audit row written with status=imprecise, no UPDATE.""" - sample = [HouseRow(id=2, address="ул Ленина", lat=None, lon=None)] - db, inserted, updated = _make_db_mock() - res = YandexReverseResult( - address="ул Ленина", - snapped_lat=56.840, - snapped_lon=60.600, - precision="street", - kind="street", - raw={"oh_well": True}, - ) - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_backfill_mode(db, sample, "b2", "KEY") - assert n == 1 - assert updated == [] - assert len(inserted) == 1 - assert inserted[0]["audit_status"] == "imprecise" - - -async def test_run_backfill_mode_no_match(): - """Yandex returns empty result → status=no_match, no UPDATE.""" - sample = [HouseRow(id=3, address="несуществующая улица 99", lat=None, lon=None)] - db, inserted, updated = _make_db_mock() - res = YandexReverseResult( - address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True} - ) - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_backfill_mode(db, sample, "b3", "KEY") - assert n == 1 - assert updated == [] - assert inserted[0]["audit_status"] == "no_match" - - -async def test_run_backfill_mode_http_error_marks_error(): - sample = [HouseRow(id=4, address="ул X 1", lat=None, lon=None)] - db, inserted, updated = _make_db_mock() - with patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(side_effect=httpx.HTTPError("boom")), - ): - n = await _run_backfill_mode(db, sample, "b4", "KEY") - assert n == 1 - assert updated == [] - assert inserted[0]["audit_status"] == "error" - assert "boom" in (inserted[0]["error_message"] or "") - - -# --------------------------------------------------------------------------- -# _run_audit_mode — ok / mismatch / no_match -# --------------------------------------------------------------------------- - - -async def test_run_audit_mode_ok_within_50m(): - sample = [HouseRow(id=10, address="ул Малышева 51", lat=56.838, lon=60.586)] - db, inserted, _updated = _make_db_mock(distance_value=12.5) - res = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838004, - snapped_lon=60.586155, - precision="exact", - kind="house", - raw={"r": 1}, - ) - with patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_audit_mode(db, sample, "ba1", "KEY") - assert n == 1 - assert inserted[0]["audit_status"] == "ok" - assert inserted[0]["distance_m"] == 12.5 - - -async def test_run_audit_mode_mismatch_above_50m(): - sample = [HouseRow(id=11, address="ул Ленина 5", lat=56.840, lon=60.600)] - db, inserted, _updated = _make_db_mock(distance_value=312.0) - res = YandexReverseResult( - address="Россия, Екатеринбург, улица Ленина, 7", - snapped_lat=56.841, - snapped_lon=60.601, - precision="exact", - kind="house", - raw={"r": 2}, - ) - with patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_audit_mode(db, sample, "ba2", "KEY") - assert n == 1 - assert inserted[0]["audit_status"] == "mismatch" - assert inserted[0]["distance_m"] == 312.0 - - -async def test_run_audit_mode_no_match(): - sample = [HouseRow(id=12, address="ул X 99", lat=56.0, lon=60.0)] - db, inserted, _updated = _make_db_mock() - res = YandexReverseResult( - address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True} - ) - with patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=res), - ): - n = await _run_audit_mode(db, sample, "ba3", "KEY") - assert n == 1 - assert inserted[0]["audit_status"] == "no_match" - - -# --------------------------------------------------------------------------- -# Resumability — second pass on same batch inserts 0 -# --------------------------------------------------------------------------- - - -async def test_main_resumable_skips_processed(monkeypatch): - """Run main() twice with same batch — second pass processes nothing.""" - backfill_sample = [ - {"id": 1, "address": "ул Малышева 51", "lat": None, "lon": None}, - {"id": 2, "address": "ул Ленина 5", "lat": None, "lon": None}, - ] - processed_ids: set[int] = set() - db, inserted, updated = _make_db_mock( - backfill_sample=backfill_sample, processed_ids=processed_ids - ) - - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - fake = YandexReverseResult( - address="ул Малышева 51", - snapped_lat=56.838, - snapped_lon=60.586, - precision="exact", - kind="house", - raw={"ok": True}, - ) - - with ( - patch("scripts.backfill_house_coords.SessionLocal", return_value=db), - patch( - "scripts.backfill_house_coords.forward_via_api", - new=AsyncMock(return_value=fake), - ), - ): - n1 = await main(["--batch", "resume_test"]) - assert n1 == 2 - assert len(inserted) == 2 - assert len(updated) == 2 - - inserted.clear() - updated.clear() - n2 = await main(["--batch", "resume_test"]) - assert n2 == 0 - assert inserted == [] - assert updated == [] - - -async def test_main_requires_api_key(monkeypatch): - """Without YANDEX_GEOCODER_API_KEY the script exits cleanly.""" - monkeypatch.delenv("YANDEX_GEOCODER_API_KEY", raising=False) - with pytest.raises(SystemExit): - await main(["--batch", "no_key"]) - - -async def test_main_audit_only_flag_routes_to_audit_loop(monkeypatch): - """--audit-only switches sample query + loop, no UPDATE expected.""" - audit_sample = [ - {"id": 50, "address": "ул Малышева 51", "lat": 56.838, "lon": 60.586}, - ] - db, inserted, updated = _make_db_mock(audit_sample=audit_sample, distance_value=8.0) - monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") - fake = YandexReverseResult( - address="Россия, Екатеринбург, улица Малышева, 51", - snapped_lat=56.838004, - snapped_lon=60.586155, - precision="exact", - kind="house", - raw={"r": 1}, - ) - with ( - patch("scripts.backfill_house_coords.SessionLocal", return_value=db), - patch( - "scripts.backfill_house_coords.reverse_via_api", - new=AsyncMock(return_value=fake), - ), - ): - n = await main(["--batch", "audit_run", "--audit-only"]) - assert n == 1 - assert updated == [] # audit mode never updates houses - assert inserted[0]["audit_status"] == "ok" - assert inserted[0]["distance_m"] == 8.0 diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 8aaefd7b..5ac78324 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -367,23 +367,28 @@ class TestHouseTypeMap: assert _map_house_type("brick") == "brick" assert _map_house_type("monolith") == "monolithic" assert _map_house_type("monolith_brick") == "monolithic" - assert _map_house_type("monolithic") == "monolithic" assert _map_house_type("block") == "block" assert _map_house_type("wood") == "wood" - def test_unknown_falls_back_to_panel(self): + def test_unknown_is_none_not_panel(self): + """#2674: дефолт 'panel' убран — он занижал оценку. Неизвестное → None. + + Полное покрытие camelCase-вокабуляра и skip-пути: + tests/test_house_imv_params_honesty.py. + """ from app.services.house_imv_backfill import _map_house_type - assert _map_house_type("unknown_type") == "panel" - assert _map_house_type(None) == "panel" - assert _map_house_type("") == "panel" + assert _map_house_type("unknown_type") is None + assert _map_house_type(None) is None + assert _map_house_type("") is None def test_case_insensitive(self): from app.services.house_imv_backfill import _map_house_type assert _map_house_type("PANEL") == "panel" assert _map_house_type("Brick") == "brick" - assert _map_house_type("MONOLITH_BRICK") == "monolithic" + # SCREAMING-вокабуляр Яндекса (MONOLIT_BRICK, одна «т») — реальный токен. + assert _map_house_type("MONOLIT_BRICK") == "monolithic" class TestRegionPrefix: diff --git a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py index 1b188f8a..4e7ee9e4 100644 --- a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py +++ b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py @@ -7,8 +7,11 @@ the resulting metrics dict is asserted for structure + determinism. The deals are crafted so every call ``_price_from_inputs`` makes to the 3 injected callables is recorded up-front, and so each headline ``median_ppm2`` is an exact, -predictable value (3 listings → the middle ₽/m²; no anchor / quarter-index / ДКП -mutation), which is what the recorded ``ratio_calls`` key must match. +predictable value (5 listings, symmetric around the middle ₽/m² — #oblast-E: the +listing count must clear HEADLINE_LISTINGS_MIN_N or the new sufficiency gate +suppresses the median before the spine ever calls these callables; no anchor / +quarter-index / ДКП mutation), which is what the recorded ``ratio_calls`` key +must match. NOTE: importing scripts.backtest_estimator → app.services.estimator → app.core.config.Settings REQUIRES DATABASE_URL. Set a dummy value BEFORE importing @@ -105,16 +108,21 @@ def _build_fixture() -> dict[str, Any]: deal 3: carries non-empty anchor_comps (2 comps < min_comps=4 → anchor never fires, so the median stays the radius median) + a ratio call. """ - # ── deal 1 — median of [90k, 100k, 110k] = 100k → ratio_resolver(100000.0). ── + # ── deal 1 — median of [80k,90k,100k,110k,120k] = 100k → ratio_resolver(100000.0). + # #oblast-E: 5 listings (>= HEADLINE_LISTINGS_MIN_N), symmetric around the same + # 100k median as before — below that count the new headline sufficiency gate + # would suppress the median before the spine even calls ratio_resolver. ── deal1 = _deal_record( deal_id=1, sold_ppm2=100_000.0, # SOLD эконом (< 120k) area_m2=50.0, rooms=1, listings=[ + {"price_per_m2": 80_000.0, "source": "avito"}, {"price_per_m2": 90_000.0, "source": "avito"}, {"price_per_m2": 100_000.0, "source": "avito"}, {"price_per_m2": 110_000.0, "source": "avito"}, + {"price_per_m2": 120_000.0, "source": "avito"}, ], anchor_comps=[], anchor_tier_fetched=None, @@ -124,7 +132,8 @@ def _build_fixture() -> dict[str, Any]: address="ул. Тестовая, 1", ) - # ── deal 2 — median 150k; first lot's cadnum → quarter "66:41:0204016". ── + # ── deal 2 — median 150k (5 listings, symmetric — #oblast-E, see deal 1 + # comment); first lot's cadnum → quarter "66:41:0204016". ── deal2 = _deal_record( deal_id=2, sold_ppm2=200_000.0, # SOLD бизнес (160k..220k) @@ -132,12 +141,14 @@ def _build_fixture() -> dict[str, Any]: rooms=2, listings=[ { - "price_per_m2": 140_000.0, + "price_per_m2": 130_000.0, "source": "cian", "building_cadastral_number": "66:41:0204016:350", }, + {"price_per_m2": 140_000.0, "source": "cian"}, {"price_per_m2": 150_000.0, "source": "cian"}, {"price_per_m2": 160_000.0, "source": "cian"}, + {"price_per_m2": 170_000.0, "source": "cian"}, ], anchor_comps=[], anchor_tier_fetched=None, @@ -148,16 +159,19 @@ def _build_fixture() -> dict[str, Any]: address="ул. Тестовая, 2", ) - # ── deal 3 — median 310k; anchor_comps present but below min_comps → no fire. ── + # ── deal 3 — median 310k (5 listings, symmetric — #oblast-E, see deal 1 + # comment); anchor_comps present but below min_comps → no fire. ── deal3 = _deal_record( deal_id=3, sold_ppm2=290_000.0, # SOLD элит (220k..300k) area_m2=80.0, rooms=3, listings=[ + {"price_per_m2": 290_000.0, "source": "yandex"}, {"price_per_m2": 300_000.0, "source": "yandex"}, {"price_per_m2": 310_000.0, "source": "yandex"}, {"price_per_m2": 320_000.0, "source": "yandex"}, + {"price_per_m2": 330_000.0, "source": "yandex"}, ], anchor_comps=[ {"price_per_m2": 305_000.0, "area_m2": 80.0, "rooms": 3, "floor": 5, "total_floors": 9}, diff --git a/tradein-mvp/backend/tests/test_captcha_vs_empty_detect.py b/tradein-mvp/backend/tests/test_captcha_vs_empty_detect.py new file mode 100644 index 00000000..6e6a52ab --- /dev/null +++ b/tradein-mvp/backend/tests/test_captcha_vs_empty_detect.py @@ -0,0 +1,519 @@ +"""Провайдер-уровень: детект-правило «капча vs честная пустая выдача» (#2625). + +Issue #2625: капча Циана и пустые выдачи Яндекса засчитывались как успешный +прогон (status='done', errors_count=0, lots_fetched=0) — не было сигнала для +run-level банана (см. `scraper_kit.orchestration.pipeline.run_cian_city_sweep` / +`run_yandex_city_sweep` / `run_cian_full_load` / `run_yandex_full_load`, которые +читают эти счётчики). + +Это НЕ тесты оркестрации (те — test_scraper_kit_pipeline_parity2.py:: +test_*_extraction_failed_marks_banned и соседи) — здесь проверяется именно +низкоуровневое правило на самом scraper-инстансе: + + Cian: extract_state() вернул None → state_extraction_failures++ (капча/смена + вёрстки). Валидный state с offers=[] → НЕ failure (честная пустая + выдача). + Yandex: gate-API payload/pager так и не извлеклись после retries (tarpit / + JSON-ошибка / gate-error-payload / отсутствие response.search.offers) + → gate_fetch_failures++. Валидный payload с entities=[] → НЕ failure. + +Без сети, без БД, без browser_fetcher — прямые вызовы sync/async-методов scraper'ов. +""" + +from __future__ import annotations + +import types +from unittest.mock import AsyncMock + +import pytest + + +def _cian_config() -> types.SimpleNamespace: + return types.SimpleNamespace(glitchtip_dsn=None) + + +# ── Cian: Redux-state extraction (_extract_state_tracked) ───────────────────── + + +def test_cian_parse_serp_html_captcha_marks_failure() -> None: + """extract_state() → None (капча/смена вёрстки) → attempts=1, failures=1.""" + from scraper_kit.providers.cian.serp import CianScraper + + scraper = CianScraper(_cian_config()) + lots = scraper._parse_serp_html("captcha page, no window._cianConfig") + + assert lots == [] + assert scraper.state_extraction_attempts == 1 + assert scraper.state_extraction_failures == 1 + + +def test_cian_parse_serp_html_honest_empty_not_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """Валидный state с offers=[] (честная пустая выдача) → attempts=1, failures=0.""" + from scraper_kit.providers.cian import serp as cian_serp + + monkeypatch.setattr( + cian_serp, + "extract_state", + lambda html, mfe, key: {"results": {"offers": [], "totalOffers": 0}}, + ) + scraper = cian_serp.CianScraper(_cian_config()) + lots = scraper._parse_serp_html("valid empty SERP") + + assert lots == [] + assert scraper.state_extraction_attempts == 1 + assert scraper.state_extraction_failures == 0 + + +def test_cian_mixed_captcha_then_honest_empty_only_first_counts_as_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Партиция «не все запросы прогона провалились» на уровне одного scraper'а: + один captcha-fail + один честный 0 → attempts=2, failures=1 (не 2).""" + from scraper_kit.providers.cian import serp as cian_serp + + scraper = cian_serp.CianScraper(_cian_config()) + + monkeypatch.setattr(cian_serp, "extract_state", lambda html, mfe, key: None) + scraper._parse_serp_html("captcha") + + monkeypatch.setattr( + cian_serp, + "extract_state", + lambda html, mfe, key: {"results": {"offers": [], "totalOffers": 0}}, + ) + scraper._parse_serp_html("honest empty") + + assert scraper.state_extraction_attempts == 2 + assert scraper.state_extraction_failures == 1 + + +def test_cian_extract_total_offers_shares_same_tracked_counters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_extract_total_offers (probe path, full_load bisection) — тот же trackер, + что _parse_serp_html (SERP-парсинг): оба должны учитываться в run-level детекте.""" + from scraper_kit.providers.cian import serp as cian_serp + + monkeypatch.setattr(cian_serp, "extract_state", lambda html, mfe, key: None) + scraper = cian_serp.CianScraper(_cian_config()) + + total = scraper._extract_total_offers("captcha on probe") + + assert total is None + assert scraper.state_extraction_attempts == 1 + assert scraper.state_extraction_failures == 1 + + +# ── Yandex: gate-API structure extraction (_track_gate_result) ──────────────── + + +class _FakeGateBrowser: + """BrowserFetcher-заглушка: fetch() возвращает заранее заданные тела ответа + (по одному на вызов, FIFO) — camoufox-обёртку
{...}
не эмулируем, + _http_get достаёт JSON через первый `{` fallback (см. _extract_json_from_content).""" + + def __init__(self, bodies: list[str]) -> None: + self._bodies = list(bodies) + self.calls = 0 + + async def fetch(self, url: str) -> str: + self.calls += 1 + return self._bodies.pop(0) + + +def _yandex_scraper_with_bodies(bodies: list[str]) -> object: + from scraper_kit.providers.yandex.serp import YandexRealtyScraper + + scraper = YandexRealtyScraper(types.SimpleNamespace()) + scraper._browser = _FakeGateBrowser(bodies) # type: ignore[assignment] + return scraper + + +class _RaisingBrowser: + """BrowserFetcher-заглушка: fetch() ВСЕГДА поднимает исключение (transport + failure — сеть/browser сбой, НЕ content-ответ) — deep-review fix 1 (#2600).""" + + async def fetch(self, url: str) -> str: + raise RuntimeError("connection reset by peer") + + +def _yandex_scraper_with_raising_browser() -> object: + from scraper_kit.providers.yandex.serp import YandexRealtyScraper + + scraper = YandexRealtyScraper(types.SimpleNamespace()) + scraper._browser = _RaisingBrowser() # type: ignore[assignment] + return scraper + + +@pytest.mark.asyncio +async def test_yandex_fetch_page_json_gate_error_marks_failure() -> None: + """gate-error payload (нет response.search.offers) → attempts=1, failures=1.""" + scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}']) + + payload = await scraper._fetch_page_json(None, 1, None, None) + + assert payload is None + assert scraper.gate_fetch_attempts == 1 + assert scraper.gate_fetch_failures == 1 + + +@pytest.mark.asyncio +async def test_yandex_fetch_page_json_honest_empty_not_a_failure() -> None: + """Валидный payload с entities=[] (честная пустая выдача) → attempts=1, failures=0.""" + body = ( + '{"response": {"search": {"offers": ' + '{"entities": [], "pager": {"page": 0, "totalItems": 0, "totalPages": 0}}}}}' + ) + scraper = _yandex_scraper_with_bodies([body]) + + payload = await scraper._fetch_page_json(None, 1, None, None) + + assert payload is not None + assert scraper.gate_fetch_attempts == 1 + assert scraper.gate_fetch_failures == 0 + + +@pytest.mark.asyncio +async def test_yandex_mixed_tarpit_then_honest_empty_only_first_counts_as_failure() -> None: + """Один gate-error fail + один честный 0 → attempts=2, failures=1 (не 2).""" + ok_body = ( + '{"response": {"search": {"offers": ' + '{"entities": [], "pager": {"page": 0, "totalItems": 0, "totalPages": 0}}}}}' + ) + scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}', ok_body]) + + await scraper._fetch_page_json(None, 1, None, None) + await scraper._fetch_page_json(None, 2, None, None) + + assert scraper.gate_fetch_attempts == 2 + assert scraper.gate_fetch_failures == 1 + + +# ── Code-review addendum (#2625): response-без-offers schema drift ──────────── +# +# {"response": {...}} без вложенного search.offers (schema drift / заглушка) +# проходит _is_gate_error как «не ошибка» (есть "response", нет "error"), но +# _extract_gate_data на нём вернёт None. Гэп был в двух местах: +# 1. _fetch_page_json (probe/degraded/leaf — full_load путь) трекал ok=True +# по одному лишь _is_gate_error, не проверяя реальное наличие offers. +# 2. fetch_around page>=2 (city_sweep пагинация) — то же самое. +# fetch_around_multi_room page=1 уже делал это правильно (эталон). + + +@pytest.mark.asyncio +async def test_yandex_fetch_page_json_schema_drift_marks_failure_not_success() -> None: + """(a) response есть, но БЕЗ search.offers → _is_gate_error пропускает, + _extract_gate_data проваливается → failure затрекан (не честный успех).""" + body = '{"response": {"someOtherField": 1}}' + scraper = _yandex_scraper_with_bodies([body]) + + payload = await scraper._fetch_page_json(None, 1, None, None) + + assert payload is not None # контракт возврата не меняется + assert scraper.gate_fetch_attempts == 1 + assert scraper.gate_fetch_failures == 1 + + +@pytest.mark.asyncio +async def test_yandex_fetch_around_page_ge2_schema_drift_marks_failure_anti_flap() -> None: + """(b) page>=2 в fetch_around (пагинация внутри fetch_around_multi_room) — + тот же drift-shape ПОСЛЕ успешной page=1 → failure затрекан, а не «честный + конец выдачи». attempts=2, failures=1 — партиция НЕ триггерит orchestration + banned-gate (тот требует failures==attempts, см. run_yandex_city_sweep).""" + ok_body = ( + '{"response": {"search": {"offers": ' + '{"entities": [{"offerId": "1", "price": {"value": 5000000}}], ' + '"pager": {"page": 0, "totalItems": 1, "totalPages": 2}}}}}' + ) + drift_body = '{"response": {"someOtherField": 1}}' + scraper = _yandex_scraper_with_bodies([ok_body, drift_body]) + scraper.request_delay_sec = 0.0 # skip real inter-request sleep in test + + lots_p1 = await scraper.fetch_around(56.84, 60.60, page=1) + lots_p2 = await scraper.fetch_around(56.84, 60.60, page=2) + + assert len(lots_p1) == 1 + assert lots_p2 == [] + assert scraper.gate_fetch_attempts == 2 + assert scraper.gate_fetch_failures == 1 # только page2, не весь прогон + + +@pytest.mark.asyncio +async def test_yandex_fetch_page_json_one_request_one_attempt_no_double_count() -> None: + """(c) Инвариант «1 запрос = 1 attempt»: caller (как _probe) сам вызывает + _extract_gate_data на уже полученном payload — это НЕ второй track-вызов, + счётчик мутируется только внутри _fetch_page_json.""" + from scraper_kit.providers.yandex.serp import _extract_gate_data + + body = '{"response": {"someOtherField": 1}}' + scraper = _yandex_scraper_with_bodies([body]) + + payload = await scraper._fetch_page_json(None, 1, None, None) + assert scraper.gate_fetch_attempts == 1 + assert scraper.gate_fetch_failures == 1 + + # Caller-side re-check (то, что реально делает _probe) — не трогает счётчики. + result = _extract_gate_data(payload) if payload is not None else None + assert result is None + assert scraper.gate_fetch_attempts == 1 + + +# ── __aexit__: report_ban на 100% failure (#2600 п.1 + deep-review fix 1) ────── +# +# Здесь репортится РАНЬШЕ, чем pipeline.py's run-level banned-статус (#2625) — в +# __aexit__ ДО release lease (см. CianScraper/YandexRealtyScraper __aexit__ +# docstring-комментарии). Floor attempts>=_MIN_ATTEMPTS_FOR_BAN_REPORT (=3, deep- +# review fix 1) — единичный admin-прогон (ровно 1 fetch_around) не должен банить +# здоровый узел на 1/1=100%. + + +class _FakeBrowserLease: + """BrowserFetcher-заглушка с report_ban-recorder + async __aexit__ (для + CianScraper/YandexRealtyScraper.__aexit__, который её awaits).""" + + def __init__(self) -> None: + self.banned: list[str] = [] + self.aexit_called = False + + def report_ban(self, reason: str) -> None: + self.banned.append(reason) + + async def __aexit__(self, *args: object) -> None: + self.aexit_called = True + + +@pytest.mark.asyncio +async def test_cian_aexit_reports_ban_on_all_attempts_failed() -> None: + from scraper_kit.providers.cian.serp import CianScraper + + scraper = CianScraper(_cian_config()) + for _ in range(3): # floor: attempts >= _MIN_ATTEMPTS_FOR_BAN_REPORT + scraper._parse_serp_html("captcha page, no window._cianConfig") + assert scraper.state_extraction_attempts == 3 + assert scraper.state_extraction_failures == 3 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned # report_ban вызван ДО release (__aexit__ дошёл до конца) + assert fake_browser.aexit_called # release всё равно случился (browser.__aexit__) + + +@pytest.mark.asyncio +async def test_cian_aexit_no_ban_report_below_attempts_floor() -> None: + """deep-review fix 1: attempts=1 (единичный admin-прогон, 100% failure) НЕ + репортит бан — floor attempts>=_MIN_ATTEMPTS_FOR_BAN_REPORT его не пускает.""" + from scraper_kit.providers.cian.serp import CianScraper + + scraper = CianScraper(_cian_config()) + scraper._parse_serp_html("captcha page, no window._cianConfig") + assert scraper.state_extraction_attempts == 1 + assert scraper.state_extraction_failures == 1 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned == [] + + +@pytest.mark.asyncio +async def test_cian_aexit_no_ban_report_on_honest_empty(monkeypatch: pytest.MonkeyPatch) -> None: + """Честная пустая выдача (attempts>0, failures=0) — report_ban НЕ вызывается.""" + from scraper_kit.providers.cian import serp as cian_serp + + monkeypatch.setattr( + cian_serp, + "extract_state", + lambda html, mfe, key: {"results": {"offers": [], "totalOffers": 0}}, + ) + scraper = cian_serp.CianScraper(_cian_config()) + scraper._parse_serp_html("valid empty SERP") + assert scraper.state_extraction_failures == 0 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned == [] + + +@pytest.mark.asyncio +async def test_cian_aexit_no_ban_report_when_no_attempts_made() -> None: + """attempts=0 (scraper упал до первого fetch) — guard attempts>0 не даёт ложного + 100%-failure на пустой выборке из нуля попыток.""" + from scraper_kit.providers.cian.serp import CianScraper + + scraper = CianScraper(_cian_config()) + assert scraper.state_extraction_attempts == 0 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned == [] + + +@pytest.mark.asyncio +async def test_yandex_aexit_reports_ban_on_all_attempts_failed() -> None: + scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}'] * 3) + for _ in range(3): # floor: attempts >= _MIN_ATTEMPTS_FOR_BAN_REPORT + await scraper._fetch_page_json(None, 1, None, None) + assert scraper.gate_fetch_attempts == 3 + assert scraper.gate_fetch_failures == 3 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned + assert fake_browser.aexit_called + + +@pytest.mark.asyncio +async def test_yandex_aexit_no_ban_report_below_attempts_floor() -> None: + """deep-review fix 1: attempts=1 (единичный admin-прогон, 100% failure) НЕ + репортит бан — тот же floor, что cian.""" + scraper = _yandex_scraper_with_bodies(['{"error": "captcha"}']) + await scraper._fetch_page_json(None, 1, None, None) + assert scraper.gate_fetch_attempts == 1 + assert scraper.gate_fetch_failures == 1 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned == [] + + +@pytest.mark.asyncio +async def test_yandex_aexit_no_ban_report_on_honest_empty() -> None: + body = ( + '{"response": {"search": {"offers": ' + '{"entities": [], "pager": {"page": 0, "totalItems": 0, "totalPages": 0}}}}}' + ) + scraper = _yandex_scraper_with_bodies([body]) + await scraper._fetch_page_json(None, 1, None, None) + assert scraper.gate_fetch_failures == 0 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned == [] + + +# ── Yandex: transport vs content failure (deep-review fix 1, #2600 п.4) ──────── +# +# _http_get: fetch() raising (no response at all) -> transport_error=True. +# Callers (_fetch_page_json/fetch_around/fetch_around_multi_room's page=1 probe) +# must NOT feed transport_error=True into _track_gate_result — that counter feeds +# report_ban (пул/бан), a transport failure is "наш прокси сдох"/сетевой сбой +# (mark_health(ok=False) already covers it inside BrowserFetcher._post_fetch). + + +@pytest.mark.asyncio +async def test_http_get_transport_exception_sets_transport_error_flag() -> None: + scraper = _yandex_scraper_with_raising_browser() + resp = await scraper._http_get("https://realty.yandex.ru/gate/x", timeout=60) + assert resp.status_code == 0 + assert resp.transport_error is True + + +@pytest.mark.asyncio +async def test_http_get_no_json_found_is_not_transport_error() -> None: + """Ответ пришёл (HTTP 200-эквивалент camoufox), но JSON не извлёкся — content + ambiguous (тарпит-страница), НЕ transport_error.""" + scraper = _yandex_scraper_with_bodies(["no json here"]) + resp = await scraper._http_get("https://realty.yandex.ru/gate/x", timeout=60) + assert resp.status_code == 0 + assert resp.transport_error is False + + +@pytest.mark.asyncio +async def test_yandex_fetch_page_json_transport_error_does_not_track() -> None: + """(a) fetch() raised (transport) — _fetch_page_json НЕ инкрементит gate_fetch_*.""" + scraper = _yandex_scraper_with_raising_browser() + + payload = await scraper._fetch_page_json(None, 1, None, None) + + assert payload is None + assert scraper.gate_fetch_attempts == 0 + assert scraper.gate_fetch_failures == 0 + + +@pytest.mark.asyncio +async def test_yandex_fetch_around_all_transport_failures_not_tracked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """(a) fetch_around: ВСЕ retry-попытки — transport exception (сеть мертва целиком) + -> retries-exhausted НЕ репортится как gate-failure (had_content_failure=False).""" + from scraper_kit.providers.yandex import serp as yandex_serp + + monkeypatch.setattr(yandex_serp.asyncio, "sleep", AsyncMock()) # без реальных 2s×N + scraper = _yandex_scraper_with_raising_browser() + + lots = await scraper.fetch_around(56.8, 60.6, page=1) + + assert lots == [] + assert scraper.gate_fetch_attempts == 0 + assert scraper.gate_fetch_failures == 0 + + +@pytest.mark.asyncio +async def test_yandex_fetch_around_mixed_transport_then_content_failure_is_tracked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Микс: первая попытка — transport exception (не считается), retry рвёт JSON + (content-сигнал, тарпит) -> had_content_failure=True -> retries-exhausted ВСЁ + ЖЕ репортится (хотя бы одна попытка дала реальный content-сигнал).""" + from scraper_kit.providers.yandex import serp as yandex_serp + from scraper_kit.providers.yandex.serp import YandexRealtyScraper + + monkeypatch.setattr(yandex_serp.asyncio, "sleep", AsyncMock()) # без реальных 2s×N + + class _FlakyThenTarpitBrowser: + def __init__(self) -> None: + self.calls = 0 + + async def fetch(self, url: str) -> str: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("transient network blip") + return "no json here (tarpit)" + + scraper = YandexRealtyScraper(types.SimpleNamespace()) + scraper._browser = _FlakyThenTarpitBrowser() # type: ignore[assignment] + + lots = await scraper.fetch_around(56.8, 60.6, page=1) + + assert lots == [] + assert scraper.gate_fetch_attempts == 1 + assert scraper.gate_fetch_failures == 1 + + +@pytest.mark.asyncio +async def test_yandex_end_to_end_transport_errors_never_report_ban() -> None: + """(a) end-to-end: 3 подряд transport-провала (сеть мертва) — gate_fetch_attempts + остаётся 0, __aexit__ guard (attempts>=floor) не срабатывает -> report_ban НЕ + вызывается. Отличимо от настоящего 3x content-бана (test_yandex_aexit_reports_ + ban_on_all_attempts_failed выше, ГДЕ attempts=3 и banned непусто).""" + scraper = _yandex_scraper_with_raising_browser() + for _ in range(3): + await scraper._fetch_page_json(None, 1, None, None) + assert scraper.gate_fetch_attempts == 0 + + fake_browser = _FakeBrowserLease() + scraper._browser = fake_browser # type: ignore[assignment] + + await scraper.__aexit__(None, None, None) + + assert fake_browser.banned == [] diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index e41a2790..1c44a0e6 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -21,9 +21,13 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from contextlib import contextmanager -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment +from scraper_kit.providers.cian.detail import ( + DetailEnrichment, + fetch_detail, + save_detail_enrichment, +) from scraper_kit.providers.cian.valuation import CianValuationResult, _save_to_cache # ── Part 1: bti_data → houses (detail.py) ──────────────────────────────────── @@ -190,6 +194,58 @@ def test_bti_missing_listing_address_skips_resolve(): db.commit.assert_called_once() +# ── Part 1b: bti_data реально извлекается с живой страницы (регрессия) ─────── +# +# Тесты выше кормят `bti_data` напрямую в `save_detail_enrichment`, поэтому они +# оставались зелёными, пока `fetch_detail` на живых страницах НИКОГДА его не +# заполнял: bti читался только как соседний с `defaultState` ключ контейнера +# frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — `offerData.bti`. +# Итог на проде: 628 detail-обогащённых Cian-листингов, 0 домов с BTI-полями. +# Здесь прогоняем настоящий сохранённый HTML через настоящий `fetch_detail`. + + +def _detail_fixture_html() -> str: + path = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") + with open(path, encoding="utf-8") as fh: + return fh.read() + + +async def test_fetch_detail_extracts_bti_from_offer_data_on_real_page(): + """Реальная страница Cian: bti лежит в offerData → bti_data должен заполниться.""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=_detail_fixture_html()) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data, "bti_data пуст — BTI-персист (#2435) в проде остаётся no-op" + # Поля, которые пишет _persist_cian_bti_house (см. Part 1). + assert result.bti_data["entrances"] == 3 + assert result.bti_data["flatCount"] == 24 + assert result.bti_data["isEmergency"] is False + assert result.bti_data["houseHeatSupplyType"] == "central" + assert result.bti_data["houseGasSupplyType"] == "central" + assert result.bti_data["houseOverlapType"] == "concrete" + + +async def test_fetch_detail_bti_legacy_sibling_location_still_read(): + """Фоллбэк: если Cian вернёт bti соседним ключом контейнера — читаем и оттуда.""" + html = ( + "" + ) + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=html) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/777/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data == {"seriesName": "1-464", "flatCount": 58} + + # ── Part 2: valuation house_info/managementCompany → houses (valuation.py) ── _HOUSE_INFO_SAMPLE = [ diff --git a/tradein-mvp/backend/tests/test_cian_views_parse.py b/tradein-mvp/backend/tests/test_cian_views_parse.py new file mode 100644 index 00000000..55db5237 --- /dev/null +++ b/tradein-mvp/backend/tests/test_cian_views_parse.py @@ -0,0 +1,70 @@ +"""#2669: Cian отдаёт просмотры фразой — разбор обязан доставать из неё числа. + +Замер на проде до правки: `listings.views_total` пуст у ВСЕХ 21 799 cian-строк +(0 из 1 571 detail-обогащённых), при том что у avito 12 350/12 352, у domklik +6 296/6 296, у yandex 1 146/1 210 — потеря ровно одна и только у Cian. + +Корень: `_parse_views` звала `int()` по всей фразе +`"146 просмотров, 8 за сегодня"` → ValueError → None (except в самой функции). + +Тесты гоняют настоящий сохранённый ответ (fixtures/cian_flat_330982715.html) +через настоящий `fetch_detail` + перебирают формы фразы. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.providers.cian.detail import _parse_views, fetch_detail + +_FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") + +# Дословно из фикстуры: "stats":{"totalViewsFormattedString":"146 просмотров, 8 за сегодня"} +_FIXTURE_PHRASE = "146 просмотров, 8 за сегодня" + + +def _fixture_html() -> str: + with open(_FIXTURE, encoding="utf-8") as fh: + return fh.read() + + +def test_fixture_still_carries_the_phrase() -> None: + """Страховка: тесты ниже бессмысленны, если фикстура перестала содержать фразу.""" + assert f'"totalViewsFormattedString":"{_FIXTURE_PHRASE}"' in _fixture_html() + + +@pytest.mark.parametrize( + ("phrase", "expected"), + [ + (_FIXTURE_PHRASE, (146, 8)), # форма из сохранённого ответа + ("1 просмотр", (1, None)), # единственное число, хвоста нет + ("2 просмотра", (2, None)), + ("0 просмотров", (0, None)), # ноль — это 0, а не «нет данных» + ("1 234 просмотра", (1234, None)), # разделитель тысяч — обычный пробел + ("1\xa0234 просмотра", (1234, None)), # ... и NBSP + ("12 345 просмотров, 1\xa0234 за сегодня", (12345, 1234)), # narrow NBSP + ("1 234", (1234, None)), # голое число (форма из старого докстринга) + ("", (None, None)), + (None, (None, None)), + ("просмотров нет", (None, None)), # без цифр — пусто, а не 0 + ], +) +def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None: + assert _parse_views(phrase) == expected + + +async def test_fetch_detail_fills_views_from_real_saved_page() -> None: + """Настоящая сохранённая страница → views_total/views_today непусты (#2669).""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=_fixture_html()) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) + + assert result is not None + assert result.views_total == 146, "views_total снова теряется — колонка останется пустой" + assert result.views_today == 8 diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 55cdfb4d..3f132e1f 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -27,6 +27,41 @@ def test_ekb_anchors_count() -> None: assert isinstance(name, str) and name +# ── resolve_city_name (#2594) ──────────────────────────────────────────────── + + +def test_resolve_city_name_known_oblast_slugs() -> None: + """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.""" + from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name + + expected = { + "nizhniy_tagil": "Нижний Тагил", + "kamensk_uralskiy": "Каменск-Уральский", + "pervouralsk": "Первоуральск", + "verkhnyaya_pyshma": "Верхняя Пышма", + "serov": "Серов", + } + # CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS + # (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени). + assert set(expected) == set(CITY_LOCATIONS) + for slug, name in expected.items(): + assert resolve_city_name(slug) == name + + +def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None: + """city_slug=None — ЕКБ-развёртка той же функции, НЕ «город неизвестен» (#2594 симметрия).""" + from scraper_kit.orchestration.pipeline import EKATERINBURG_CITY_NAME, resolve_city_name + + assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург" + + +def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: + """Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors.""" + from scraper_kit.orchestration.pipeline import resolve_city_name + + assert resolve_city_name("nonexistent_city") == "Екатеринбург" + + # ── CitySweepCounters ─────────────────────────────────────────────────────── @@ -115,7 +150,9 @@ def test_scrape_runs_mark_cancelled_returns_bool() -> None: from app.services.scrape_runs import mark_cancelled mock_db = MagicMock() - mock_db.execute.return_value.fetchone.return_value = MagicMock() # row found + # source обязателен: #2674 добавил гейт honors_cancel — отменять можно только то, + # что отмену опрашивает (иначе 'cancelled' у живой задачи + обход has_running_run). + mock_db.execute.return_value.fetchone.return_value = MagicMock(source="avito_city_sweep") result = mark_cancelled(mock_db, 10) assert result is True @@ -136,9 +173,15 @@ def test_scrape_runs_mark_cancelled_returns_false_when_not_running() -> None: def _captured_params(mock_db: MagicMock) -> dict: - """Извлечь dict bind-параметров из последнего db.execute(text(...), params).""" - assert mock_db.execute.call_args is not None, "db.execute was not called" - return mock_db.execute.call_args.args[1] + """Извлечь dict bind-параметров из ПЕРВОГО db.execute(text(...), params). + + Первый вызов — всегда основной UPDATE (mark_done/update_heartbeat). mark_done + (#2625) может выполнить дополнительные db.execute() ПОСЛЕ него для + zero-result-алерта (_alert_on_run_id → SELECT source / SELECT streak) — + call_args_list[0] остаётся стабильным независимо от этого хвоста. + """ + assert mock_db.execute.call_args_list, "db.execute was not called" + return mock_db.execute.call_args_list[0].args[1] def test_column_counts_maps_lots_fetched_inserted() -> None: @@ -184,8 +227,8 @@ def test_mark_done_persists_total_seen_new_count_columns() -> None: params = _captured_params(mock_db) assert params["total_seen"] == 200 assert params["new_count"] == 18 - # SQL must SET the dedicated columns, not only the jsonb blob - sql = str(mock_db.execute.call_args.args[0]) + # SQL must SET the dedicated columns, not only the jsonb blob (first call = UPDATE) + sql = str(mock_db.execute.call_args_list[0].args[0]) assert "total_seen" in sql assert "new_count" in sql mock_db.commit.assert_called() diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_health_gate.py b/tradein-mvp/backend/tests/test_deactivate_stale_health_gate.py new file mode 100644 index 00000000..89d4ef30 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_health_gate.py @@ -0,0 +1,321 @@ +"""Гейт по здоровью сбора для TTL-деактивации (#2659). + +Ключевой тест здесь — test_gate_blocks_every_day_of_the_17_day_avito_ban: он +проигрывает РЕАЛЬНЫЙ прод-ряд подтверждений по дням и требует, чтобы порог из +миграции 219 заблокировал каждые сутки провала 10.07-26.07.2026 и не тронул ни +одних здоровых суток. На старом коде (без min_confirmations) он не проходит: +деактивация исполнялась вслепую. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import deactivate_stale_avito as task_mod + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_219 = _SQL_DIR / "219_deactivate_stale_health_gate.sql" + +# ── Прод-ряд: подтверждений за 3 суток по дням, avito, все сегменты ──────────── +# Восстановлено из listing_source_snapshots (снимок last_seen_at на каждую дату): +# count(*) FILTER (WHERE last_seen_at > snapshot_date - interval '3 days') +# Провал сбора: 08.07-29.07.2026 (17 суток, за которые TTL=10 снял 9 033 строки). +# Здоровые сутки — до 07.07 и после восстановления 03.08. +_AVITO_BAN_DAYS: dict[str, int] = { + "2026-07-08": 936, + "2026-07-09": 970, + "2026-07-10": 830, + "2026-07-11": 300, + "2026-07-13": 160, + "2026-07-14": 683, + "2026-07-15": 683, + "2026-07-16": 665, + "2026-07-17": 0, + "2026-07-18": 0, + "2026-07-19": 0, + "2026-07-20": 0, + "2026-07-21": 0, + "2026-07-22": 0, + "2026-07-23": 0, + "2026-07-24": 0, + "2026-07-25": 0, + "2026-07-27": 0, + "2026-07-28": 0, + "2026-07-29": 0, +} +_AVITO_HEALTHY_DAYS: dict[str, int] = { + "2026-06-25": 3542, + "2026-06-26": 3902, + "2026-06-27": 4017, + "2026-06-28": 3894, + "2026-06-29": 4424, + "2026-06-30": 4943, + "2026-07-01": 5270, + "2026-07-02": 4828, + "2026-07-05": 6079, + "2026-07-06": 5060, + "2026-07-07": 3666, + "2026-08-03": 4039, + "2026-08-04": 4247, + "2026-08-05": 4197, + "2026-08-06": 2542, +} +# Порог из миграции 219 для deactivate_stale_avito. +_AVITO_MIN_CONFIRMATIONS = 1500 + +# Сколько строк TTL снял в каждые сутки провала (scrape_runs.counters->>'deactivated'). +# Сумма = 9 033 — цифра из #2659, перепроверена на проде. +_AVITO_BAN_DEACTIVATED = [316, 970, 742, 676, 1541, 2959, 216, 536, 146, 95, 153, 0, 0, 683] + + +# ── Фейковая сессия ─────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: int | None = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> int | None: + return self._scalar + + +class _FakeDB: + """Session-заглушка: SELECT count(*) отдаёт confirmations, UPDATE — rowcount.""" + + def __init__(self, *, confirmations: int, rowcount: int = 137) -> None: + self._confirmations = confirmations + self._rowcount = rowcount + self.executed: list[tuple[str, dict[str, Any] | None]] = [] + self.committed = False + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt.text) + self.executed.append((sql, params)) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._confirmations) + return _FakeResult(rowcount=self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + self.rolled_back = True + + @property + def update_statements(self) -> list[str]: + return [sql for sql, _ in self.executed if "UPDATE listings" in sql] + + +def _run( + db: _FakeDB, + monkeypatch: pytest.MonkeyPatch, + **kwargs: Any, +) -> dict[str, int]: + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + return task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 1, + listing_source=kwargs.pop("listing_source", "avito"), + ttl_days=kwargs.pop("ttl_days", 10), + **kwargs, + ) + + +# ── Исторический случай: 17 суток бана Авито ────────────────────────────────── + + +def test_gate_blocks_every_day_of_the_17_day_avito_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одни сутки провала 08.07-29.07 не должны пропустить деактивацию.""" + for day, confirmations in _AVITO_BAN_DAYS.items(): + db = _FakeDB(confirmations=confirmations) + out = _run(db, monkeypatch, min_confirmations=_AVITO_MIN_CONFIRMATIONS) + assert out["skipped_unhealthy"] == 1, f"{day}: гейт пропустил провальные сутки" + assert out["deactivated"] == 0, f"{day}: деактивировано ненулевое количество" + assert db.update_statements == [], f"{day}: UPDATE listings всё-таки исполнился" + + +def test_gate_passes_every_healthy_avito_day(monkeypatch: pytest.MonkeyPatch) -> None: + """Здоровые сутки порог 1500 не блокирует — гейт не ломает штатную работу.""" + for day, confirmations in _AVITO_HEALTHY_DAYS.items(): + db = _FakeDB(confirmations=confirmations) + out = _run(db, monkeypatch, min_confirmations=_AVITO_MIN_CONFIRMATIONS) + assert "skipped_unhealthy" not in out, f"{day}: гейт заблокировал здоровые сутки" + assert out["deactivated"] == 137, f"{day}: деактивация не исполнилась" + assert len(db.update_statements) == 1, f"{day}: UPDATE listings не исполнился" + + +def test_threshold_separates_ban_from_health() -> None: + """Порог лежит строго между максимумом провала и минимумом здоровых суток.""" + assert max(_AVITO_BAN_DAYS.values()) < _AVITO_MIN_CONFIRMATIONS + assert min(_AVITO_HEALTHY_DAYS.values()) > _AVITO_MIN_CONFIRMATIONS + + +def test_ban_window_damage_matches_issue_number() -> None: + """Ущерб исторического случая — 9 033 строки (#2659), гейт спасает их все.""" + assert sum(_AVITO_BAN_DEACTIVATED) == 9033 + + +# ── Контракт гейта ──────────────────────────────────────────────────────────── + + +def test_gate_disabled_by_default_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None: + """min_confirmations=0 -> ни одного лишнего запроса, поведение как до #2659.""" + db = _FakeDB(confirmations=0) + out = _run(db, monkeypatch) + assert out == {"deactivated": 137} + assert len(db.executed) == 1 + + +def test_gate_reports_confirmations_when_passing(monkeypatch: pytest.MonkeyPatch) -> None: + """Прошедший гейт прогон всё равно пишет замер — счётчик виден оператору.""" + db = _FakeDB(confirmations=4000) + out = _run(db, monkeypatch, min_confirmations=1500) + assert out["confirmations"] == 4000 + assert out["deactivated"] == 137 + + +def test_gate_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None: + """SELECT-проверка идёт ПЕРВОЙ: деактивация необратима, откат после неё не спасает.""" + db = _FakeDB(confirmations=4000) + _run(db, monkeypatch, min_confirmations=1500) + assert "SELECT count(*)" in db.executed[0][0] + assert "UPDATE listings" in db.executed[1][0] + + +def test_blocked_run_does_not_commit(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(confirmations=10) + _run(db, monkeypatch, min_confirmations=1500) + assert db.committed is False + assert db.rolled_back is True + + +def test_blocked_run_is_finalised_as_done(monkeypatch: pytest.MonkeyPatch) -> None: + """Пропущенный прогон закрывается mark_done, а не висит 'running' до zombie-жатвы.""" + marked: dict[str, Any] = {} + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)), + ) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + db = _FakeDB(confirmations=10) + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 77, + listing_source="avito", + ttl_days=10, + min_confirmations=1500, + ) + assert marked["run_id"] == 77 + assert marked["counters"]["skipped_unhealthy"] == 1 + assert marked["counters"]["deactivated"] == 0 + + +def test_gate_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None: + """Срез гейта совпадает со срезом UPDATE: тот же source и те же сегменты.""" + db = _FakeDB(confirmations=4000) + _run(db, monkeypatch, segments=["vtorichka"], min_confirmations=500) + health_sql, health_params = db.executed[0] + assert "ANY(CAST(:segments AS text[]))" in health_sql + assert health_params is not None + assert health_params["segments"] == ["vtorichka"] + assert health_params["listing_source"] == "avito" + + +def test_gate_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """domklik считает свежесть по scraped_at (#2204) — гейт обязан мерить ту же колонку, + иначе bulk-touch по last_seen_at показал бы здоровье там, где сбора нет.""" + db = _FakeDB(confirmations=4000) + _run(db, monkeypatch, staleness_column="scraped_at", min_confirmations=200) + health_sql = db.executed[0][0] + assert "scraped_at" in health_sql + assert "last_seen_at" not in health_sql + + +def test_gate_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None: + """Whitelist колонки работает и на пути гейта — интерполяции чужого имени нет.""" + db = _FakeDB(confirmations=4000) + with pytest.raises(ValueError): + _run(db, monkeypatch, staleness_column="is_active", min_confirmations=500) + assert db.executed == [] + + +def test_confirmations_sql_is_psycopg_v3_safe() -> None: + sql = str(task_mod._build_confirmations_sql("last_seen_at", with_segments=True).text) + assert "CAST(:health_window_days || ' days' AS interval)" in sql + assert not re.search(r":\w+::", sql) + assert "UPDATE" not in sql.upper() + assert "DELETE" not in sql.upper() + + +def test_default_min_confirmations_is_a_safety_net_not_zero() -> None: + """Незасеянное расписание получает страховку, а не «деактивируй вслепую».""" + assert task_mod.DEFAULT_MIN_CONFIRMATIONS > 0 + + +def test_handler_wires_min_confirmations_from_schedule_params() -> None: + """Читаем исходник файлом: product_handlers тянет scraper_kit, которого в + юнит-окружении может не быть, а проверяем мы проводку, а не импорт.""" + handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py" + src = handlers.read_text("utf-8") + job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0] + assert 'params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)' in job + assert "min_confirmations=min_confirmations" in job + + +# ── Миграция 219 ────────────────────────────────────────────────────────────── + + +def test_migration_219_exists() -> None: + assert _MIGRATION_219.is_file(), f"missing migration: {_MIGRATION_219}" + + +def test_migration_219_seeds_all_four_schedules() -> None: + sql = _MIGRATION_219.read_text("utf-8") + for source in ( + "deactivate_stale_avito", + "deactivate_stale_cian", + "deactivate_stale_yandex", + "deactivate_stale_domklik", + ): + assert f"'{source}'" in sql, f"{source} без порога — деактивирует вслепую" + + +def test_migration_219_avito_threshold_catches_the_ban() -> None: + """Порог авито должен быть выше максимума провальных суток (970).""" + sql = _MIGRATION_219.read_text("utf-8") + avito_block = sql.split("WHERE source = 'deactivate_stale_avito'")[0] + match = re.findall(r"'min_confirmations',\s*(\d+)", avito_block) + assert match, "порог авито не найден в миграции" + assert int(match[-1]) == _AVITO_MIN_CONFIRMATIONS + assert int(match[-1]) > max(_AVITO_BAN_DAYS.values()) + + +def test_migration_219_is_transactional_and_idempotent() -> None: + sql = _MIGRATION_219.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + # Повторный прогон не затирает подкрученное оператором значение. + assert sql.count("NOT default_params ? 'min_confirmations'") == 3 + + +def test_migration_219_touches_only_deactivate_schedules() -> None: + sql = _MIGRATION_219.read_text("utf-8") + for line in sql.splitlines(): + if line.strip().startswith("WHERE source"): + assert "deactivate_stale_" in line + + +def test_migration_219_no_psycopg_trap() -> None: + sql = _MIGRATION_219.read_text("utf-8") + assert not re.search(r":\w+::", sql) diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py new file mode 100644 index 00000000..bdb6f976 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -0,0 +1,362 @@ +"""Разбор мёртвого кода #2674: подключить / удалить / задокументировать. + +Каждая правка эпика — тест, который краснеет без неё: + + подключено: + - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница + не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»); + - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — + именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; + - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. + + удалено (гейт против возврата): + - _phys_dedup_key / _extract_street_token — обёртки без прод-вызовов; + - asking_to_sold_ratios_tiered / asking_to_sold_tier_bounds — таблицы без + читателя и писателя; + - listings.merged_into, house_sources.raw_payload — колонки без писателя; + - v_data_quality.price_disagreements_count — показатель, который не мог быть + ненулевым. + + задокументировано: + - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — + сервис обязан сказать об этом вслух на старте. + +Без БД и сети: сессия замокана, SQL-миграции читаются как текст. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.cian.valuation import _parse_valuation_state +from scraper_kit.providers.yandex.valuation import ( + ValuationHistoryItem, + ValuationHouseMeta, + YandexValuationResult, +) + +from app.services import estimator +from app.services.estimator import _save_yandex_history_items + +REPO_ROOT = Path(__file__).resolve().parents[3] +TRADEIN = REPO_ROOT / "tradein-mvp" +SQL_DIR = TRADEIN / "backend" / "data" / "sql" +MIGRATION = SQL_DIR / "216_dead_code_sweep.sql" + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 1/3: houses.has_panorama +# ───────────────────────────────────────────────────────────────────────────── + + +def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult: + return YandexValuationResult( + address="Екатеринбург, ул. Куйбышева, 106", + offer_category="APARTMENT", + offer_type="SELL", + page=1, + source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test", + house=meta, + history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)], + ) + + +def _panorama_updates(db: MagicMock) -> list[dict]: + """Параметры всех db.execute, которые обновляли houses.has_panorama.""" + found = [] + for call in db.execute.call_args_list: + sql = str(call.args[0]) + if "has_panorama" in sql and "UPDATE houses" in sql: + found.append(call.args[1]) + return found + + +def test_has_panorama_written_when_page_rendered() -> None: + """Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой.""" + db = MagicMock() + result = _result_with_meta( + ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True) + ) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(99, 0.9, "fp"), + ): + _save_yandex_history_items(db, result) + + updates = _panorama_updates(db) + assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674" + assert updates[0] == {"hid": 99, "panorama": True} + + +def test_has_panorama_false_written_when_page_rendered() -> None: + """Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false.""" + db = MagicMock() + result = _result_with_meta( + ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False) + ) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(7, 0.9, "fp"), + ): + _save_yandex_history_items(db, result) + + assert _panorama_updates(db) == [{"hid": 7, "panorama": False}] + + +def test_has_panorama_written_when_page_has_no_history() -> None: + """Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде. + + Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие + страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение + о доме к наличию объявлений отношения не имеет. + """ + db = MagicMock() + result = _result_with_meta( + ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True) + ) + result.history_items = [] + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(42, 0.9, "fp"), + ): + assert _save_yandex_history_items(db, result) == 0 + + assert _panorama_updates(db) == [{"hid": 42, "panorama": True}] + + +def test_has_panorama_not_written_when_page_unconfirmed() -> None: + """Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false.""" + db = MagicMock() + result = _result_with_meta(ValuationHouseMeta(has_panorama=False)) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(5, 0.9, "fp"), + ): + _save_yandex_history_items(db, result) + + assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали" + + +def test_has_panorama_not_written_without_house_id() -> None: + """Дом не сматчился → писать некуда, но и падать нельзя.""" + db = MagicMock() + result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16)) + + with patch( + "app.services.estimator.match_or_create_house", + side_effect=RuntimeError("no house"), + ): + _save_yandex_history_items(db, result) + + assert _panorama_updates(db) == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка +# ───────────────────────────────────────────────────────────────────────────── + + +def test_domrf_loader_has_a_scheduler_handler() -> None: + """Без Handler'а загрузчик ДОМ.РФ был невызываем — этого и не хватало.""" + from app.services.product_handlers import build_product_handlers + + handlers = build_product_handlers(MagicMock()) + assert "domrf_kapremont_load" in handlers + + +def test_domrf_loader_is_seeded_into_schedules() -> None: + """Handler без строки расписания так же нем, как расписание без Handler'а.""" + sql = MIGRATION.read_text(encoding="utf-8") + assert "'domrf_kapremont_load'" in sql + assert "INSERT INTO scrape_schedules" in sql + # Недельный такт: реестр капремонта не меняется ежедневно, а прогон качает два zip. + assert '"interval_days": 7' in sql + + +def test_domrf_window_does_not_collide_with_matview_refresh() -> None: + """Окно ДОМ.РФ не должно совпадать с refresh_search_matview (03:00-04:00 UTC). + + Ревью #2689: планировщик берёт случайный момент внутри окна и гоняет источники + параллельно — общее окно с тем заданием, которое переносит year_built в поиск, + это подбрасывание монеты. Тест ловит откат окна обратно на 3. + """ + sql = MIGRATION.read_text(encoding="utf-8") + values = sql.split("'domrf_kapremont_load',", 1)[1].split(")", 1)[0] + tokens = [t.strip().rstrip(",") for t in values.splitlines()] + hours = [int(t) for t in tokens if t.isdigit()] + assert hours, "не нашли window_start_hour/window_end_hour в INSERT" + start, end = hours[0], hours[1] + matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06 + assert end <= matview_start or start >= matview_end, ( + f"окно {start}-{end} пересекается с refresh_search_matview " + f"{matview_start}-{matview_end}" + ) + + +def test_domrf_handler_reuses_loader_functions() -> None: + """Дизайн-инвариант product_handlers: job переиспользует боевое тело, не копирует.""" + src = (TRADEIN / "backend" / "app" / "services" / "product_handlers.py").read_text( + encoding="utf-8" + ) + body = src.split("_job_domrf_kapremont_load", 1)[1].split("# ──", 1)[0] + for fn in ( + "load_domrf_kapremont", + "backfill_houses_from_domrf", + "propagate_listings_year_from_houses", + ): + assert fn in body, f"{fn} не вызывается — загрузчик подключён лишь наполовину" + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали +# ───────────────────────────────────────────────────────────────────────────── + + +def test_filters_hash_read_from_sale_wrapper() -> None: + """Прод-форма ответа: ключи estimation.sale = {isError, filtersHash, data, isFetching}.""" + state = { + "user": {"isAuthenticated": True, "userId": 1}, + "estimation": { + "sale": { + "isError": False, + "isFetching": False, + "filtersHash": "96bba2876162e2b822f80eec", + "data": {"price": 9_000_000, "accuracy": 12}, + }, + "rent": {"data": {}}, + }, + } + assert _parse_valuation_state(state).filters_hash == "96bba2876162e2b822f80eec" + + +def test_filters_hash_absent_stays_none() -> None: + """Нет ключа → None. Со старым (вложенным) путём тест бы прошёл — он не про фикс.""" + state = {"estimation": {"sale": {"data": {"price": 1}}, "rent": {"data": {}}}} + assert _parse_valuation_state(state).filters_hash is None + + +def test_filters_hash_backfill_uses_the_same_path() -> None: + """Миграция достаёт хеш ровно оттуда же, откуда его теперь читает парсер.""" + sql = MIGRATION.read_text(encoding="utf-8") + assert "'{estimation,sale,filtersHash}'" in sql + assert "WHERE filters_hash IS NULL" in sql + + +# ───────────────────────────────────────────────────────────────────────────── +# Удалено: гейты против возврата +# ───────────────────────────────────────────────────────────────────────────── + + +def _live_python_sources() -> list[Path]: + """Боевой Python trade-in: app + scraper-kit + browser. Без тестов.""" + roots = [ + TRADEIN / "backend" / "app", + TRADEIN / "packages" / "scraper-kit" / "src", + TRADEIN / "browser", + ] + return [p for root in roots for p in root.rglob("*.py") if not p.name.startswith("test_")] + + +def test_dead_dedup_wrappers_are_gone() -> None: + """Обёртки без прод-вызовов (25 ссылок, все из тестов) не должны вернуться.""" + for name in ("_phys_dedup_key", "_extract_street_token"): + assert not hasattr(estimator, name), ( + f"{name} снова в estimator — эта обёртка описывала правило, " + "которого в боевом дедупе (_union_find_phys_dedup) нет" + ) + + +def test_dead_names_absent_from_live_code() -> None: + """Имена удалённых таблиц/колонок/показателей не упоминаются в боевом коде. + + SQL-миграции сознательно НЕ проверяем: 098/028/029/046 — исторические файлы, + переписывать их задним числом нельзя (пересборка с нуля идёт по ним). + """ + dead = [ + "asking_to_sold_ratios_tiered", + "asking_to_sold_tier_bounds", + "price_disagreements_count", + ] + offenders: list[str] = [] + for path in _live_python_sources(): + text = path.read_text(encoding="utf-8") + for name in dead: + if name in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {name}") + assert not offenders, "удалённое снова упоминается: " + "; ".join(offenders) + + +def test_dropped_columns_have_no_python_writer() -> None: + """merged_into / house_sources.raw_payload: писателя не было и быть не должно.""" + offenders = [ + str(p.relative_to(REPO_ROOT)) + for p in _live_python_sources() + if "merged_into" in p.read_text(encoding="utf-8") + ] + assert not offenders, f"listings.merged_into снова упомянут: {offenders}" + + hs_writers = [ + p + for p in _live_python_sources() + if "INSERT INTO house_sources" in p.read_text(encoding="utf-8") + ] + assert hs_writers, "писатели house_sources исчезли — тест потерял смысл, проверь grep" + for path in hs_writers: + stmt = path.read_text(encoding="utf-8").split("INSERT INTO house_sources", 1)[1] + stmt = stmt.split("VALUES", 1)[0] + assert "raw_payload" not in stmt, f"{path} снова пишет удалённую колонку" + + +def test_migration_drops_exactly_what_was_declared_dead() -> None: + sql = MIGRATION.read_text(encoding="utf-8") + for stmt in ( + "DROP TABLE IF EXISTS asking_to_sold_ratios_tiered", + "DROP TABLE IF EXISTS asking_to_sold_tier_bounds", + "ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into", + "ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload", + "DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx", + ): + assert stmt in sql, f"миграция не выполняет: {stmt}" + # Показатель убран из KPI-снимка, но сам view-источник оставлен как задел. + view_ddl = sql.split("CREATE OR REPLACE VIEW v_data_quality", 1)[1].split(";", 1)[0] + assert "price_disagreements_count" not in view_ddl + assert "COMMENT ON VIEW v_price_divergence" in sql + + +def test_price_divergence_is_documented_as_structurally_empty() -> None: + """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" + sql = MIGRATION.read_text(encoding="utf-8") + comment = sql.split("COMMENT ON VIEW v_price_divergence IS", 1)[1].split(";", 1)[0] + assert ( + "match_or_create_listing" in comment + ), "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + + +# ───────────────────────────────────────────────────────────────────────────── +# Задокументировано: мёртвая переменная окружения +# ───────────────────────────────────────────────────────────────────────────── + + +def test_browser_warns_about_retired_env() -> None: + """BROWSER_BLOCK_RESOURCES выставлен в трёх прод-контейнерах и ни на что не влияет. + + Сам browser/server.py тянет aiohttp+camoufox и в backend-окружении не + импортируется, поэтому проверяем исходник: переменная обязана быть в реестре + отставных И должна логироваться предупреждением на старте. + """ + src = (TRADEIN / "browser" / "server.py").read_text(encoding="utf-8") + assert "_RETIRED_ENV" in src + assert '"BROWSER_BLOCK_RESOURCES"' in src + assert "_warn_retired_env()" in src, "предупреждение не вызывается со старта" + # Переменная НЕ должна снова начать что-то менять — только предупреждать. + read_sites = re.findall(r'os\.environ(?:\.get)?[\[(]"BROWSER_BLOCK_RESOURCES"', src) + assert not read_sites, "BROWSER_BLOCK_RESOURCES снова читается как рабочий флаг" diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 0476100c..e84f377e 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -1,10 +1,10 @@ """Offline unit tests for DomClickCitySweepCounters + DomClick parser. Legacy `run_domclick_city_sweep` orchestration behavior (sweep phases, cooperative -cancel, honest mark_done/mark_failed statuses) удалён вместе с +cancel, honest mark_done/mark_failed/mark_banned statuses) удалён вместе с `app.services.scrape_pipeline` (#2397 Part E1) — эквивалентная regression-coverage -теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_{done, -blocked_failed}` (kit `run_domclick_city_sweep`). +теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_*` +(kit `run_domclick_city_sweep`). `app.services.scrapers.domclick` удалён (#2397 Part E2, 0 runtime-импортёров) — `_map_item` ретаргетирован на kit `scraper_kit.providers.domclick.serp.DomClickScraper` @@ -88,3 +88,57 @@ def test_map_item_basic_mapping() -> None: assert lot.listing_segment == "vtorichka" assert lot.lat == pytest.approx(56.838) assert lot.lon == pytest.approx(60.612) + + +# ── fetch_city: report_ban на QRATOR-блок (#2600 п.1) ─────────────────────────── + + +class _FakeFetcher: + """Заглушка BrowserFetcher: async context manager + report_ban recorder.""" + + def __init__(self) -> None: + self.banned: list[str] = [] + + async def __aenter__(self) -> _FakeFetcher: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + def report_ban(self, reason: str) -> None: + self.banned.append(reason) + + +async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.MonkeyPatch) -> None: + """QRATOR-блок в fetch_city → fetcher.report_ban вызывается ВНУТРИ `async with + build_browser_fetcher(...) as fetcher:` (lease ещё держится) — #2600 п.1. + + no-op сегодня (domclick SERP собирается БЕЗ proxy_provider — #2160 P4 wiring + gap), но сам вызов должен произойти корректно на нужном (живом) fetcher'е. + """ + from scraper_kit.domclick_exceptions import DomClickBlockedError + + fake_fetcher = _FakeFetcher() + + def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher: + assert source == "domclick" + return fake_fetcher + + monkeypatch.setattr( + "scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher + ) + + async def _raise_blocked(self: DomClickScraper, **_: object) -> None: + raise DomClickBlockedError("QRATOR block page") + + monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _raise_blocked) + + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") + scraper = DomClickScraper(config) + + lots = await scraper.fetch_city(city_id=1) + + assert lots == [] + assert scraper.blocked is True + assert fake_fetcher.banned # report_ban был вызван на ЖИВОМ fetcher'е + assert "QRATOR" in fake_fetcher.banned[0] diff --git a/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py b/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py index 638e3bfa..6ccd81f0 100644 --- a/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py +++ b/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py @@ -260,7 +260,6 @@ async def test_geocode_uses_geoportal_first() -> None: patch("app.services.geocoder._cadastral_house_match") as mock_cad, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, ): result = await geocode("Космонавтов 7б", db) @@ -273,7 +272,6 @@ async def test_geocode_uses_geoportal_first() -> None: # geoportal hit short-circuits everything downstream mock_cad.assert_not_called() mock_forward.assert_not_called() - mock_yandex.assert_not_called() async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None: @@ -295,7 +293,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, ): result = await geocode("Серова 27", db) @@ -304,7 +301,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non mock_geo.assert_called_once() mock_cad.assert_called_once() mock_forward.assert_not_called() - mock_yandex.assert_not_called() async def test_geocode_skips_geoportal_when_parse_fails() -> None: @@ -319,14 +315,12 @@ async def test_geocode_skips_geoportal_when_parse_fails() -> None: patch("app.services.geocoder._cadastral_house_match") as mock_cad, patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=None, ), ): - mock_settings.yandex_geocoder_api_key = None result = await geocode("полный мусор без дома", db) assert result is None diff --git a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py index 93b7f502..2b8f2a3c 100644 --- a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py +++ b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py @@ -17,6 +17,7 @@ DB-facing helpers only) — the same pattern as test_estimator_radius_floor.py. from __future__ import annotations +import math import os from datetime import UTC, datetime from typing import Any @@ -100,6 +101,24 @@ def _run_estimate( # what the always-executed final fallback tier returns. return_value=(list(analogs), False, "W"), ), + # #2581: explicit, not accidental. Before this fix, `db = MagicMock()` + # was left UNCONFIGURED for `_fetch_anchor_comps` — since it's not + # patched here, it ran for REAL against the mock session, and + # `db.execute(...).mappings().all()` on a bare MagicMock silently + # returns `[]` (MagicMock's default `__iter__` == `iter([])`), so + # `_fetch_anchor_comps` ALWAYS returned `([], None)` regardless of + # what the real SQL would do. That made this whole test file blind + # to the #2581 anchor cross-city leak: `anchor_tier` could never + # observe becoming 'A' here, so a regression that makes Tier A + # wrongly match an EKB listing for a Серов/Тагил subject (which + # would then BLOCK this very deals-headline-fallback via the + # `anchor_tier is None` gate, see _price_from_inputs #oblast-D) was + # invisible. Patched explicitly now so the assumption is documented + # and intentional. The behavioral regression test itself lives in + # test_non_ekb_anchor_not_leaked_from_ekb_street_collision below, + # which does NOT patch `_fetch_anchor_comps` — it exercises the + # real SQL/geo-bound logic instead. + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), patch("app.services.estimator._fetch_deals", return_value=[]), patch( "app.services.estimator._get_or_fetch_imv_cached", @@ -205,14 +224,22 @@ def test_ekb_with_dense_listings_ignores_deals_fallback() -> None: """EKB has plenty of local listings — the radius-path headline must win, NOT the deal corridor, even though dkp_raw is present (byte-green guard: EKB must stay on the existing listings-median path unconditionally). + + #oblast-E: 5 analogs (>= HEADLINE_LISTINGS_MIN_N) — "plenty" per this + test's own docstring means the new headline sufficiency gate must NOT + suppress them; a merely-3-analog EKB sample is exactly the thin case the + gate targets (see test_estimator_headline_sufficiency.py), so it would no + longer count as "dense" post-fix. """ from app.schemas.trade_in import TradeInEstimateInput from app.services.geocoder import GeocodeResult analogs = [ - _make_listing(price_per_m2=140_000.0), + _make_listing(price_per_m2=138_000.0), + _make_listing(price_per_m2=142_000.0), _make_listing(price_per_m2=145_000.0), - _make_listing(price_per_m2=150_000.0), + _make_listing(price_per_m2=148_000.0), + _make_listing(price_per_m2=152_000.0), ] dkp_raw = { "count": 20, @@ -236,3 +263,258 @@ def test_ekb_with_dense_listings_ignores_deals_fallback() -> None: # n_analogs must reflect the real listing count (deals-fallback never ran). assert est.n_analogs == len(analogs) assert 140_000 <= est.median_price_per_m2 <= 150_000 + + +# ── #2581 regression: Tier A anchor must not leak cross-city street collisions ─ +# +# Unlike the tests above (which explicitly patch `_fetch_anchor_comps` — see +# the comment on that patch in `_run_estimate`), the tests below do NOT patch +# it: they exercise the real Tier A SQL/geo-bound logic against a hand-rolled +# `db.execute` fake that computes genuine haversine distance, mirroring what +# Postgres' ST_DWithin would decide. This is what actually catches a #2581-class +# regression; the tests above only prove the deals-fallback logic given +# anchor_tier=None as an already-resolved input. + + +def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Real great-circle distance — stands in for what Postgres ST_DWithin computes.""" + r = 6_371_000.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlmb = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +# A real EKB listing on "ул. Ленина" — SAME normalized street+house-number key +# as the Серов subject below (_normalize_building_key drops the city token), +# ~280 km away. Pre-#2581 the Tier A SQL had NO geo predicate at all, so an +# address-string match against this row would have been returned +# unconditionally — the actual reported bug (ЕКБ "Ленина 5" leaking into a +# Серов estimate at ~150-190k ₽/м² vs. the real ~30k deal corridor). +_EKB_LENINA_LISTING = { + "price_per_m2": 186_000.0, + "area_m2": 45.0, + "rooms": 2, + "floor": 5, + "total_floors": 16, + "address": "Екатеринбург, ул. Ленина, 5", + "source": "cian", + "source_url": "https://cian.ru/sale/flat/leak1/", + "price_rub": 186_000.0 * 45.0, + "listing_date": None, + "days_on_market": 12, + "photo_urls": [], + "lat": 56.838, + "lon": 60.595, + "listing_segment": "vtorichka", + "source_id": "leak1", +} + + +def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]: + """N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for + Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`). + Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265, + street+floor+area+price physical key) treats them as distinct units, not + cross-posted duplicates of the same lot. + """ + return [ + { + **_EKB_LENINA_LISTING, + "source_id": f"leak{i}", + "source_url": f"https://cian.ru/sale/flat/leak{i}/", + "floor": 3 + 2 * i, + "price_per_m2": ppm2, + "price_rub": ppm2 * _EKB_LENINA_LISTING["area_m2"], + } + for i, ppm2 in enumerate(prices_per_m2) + ] + + +def _serov_geo() -> Any: + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=59.6047, + lon=60.5876, + full_address="Свердловская обл., Серов, ул. Ленина, 5", + provider="nominatim", + ) + + +def _serov_payload() -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="Серов, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=9 + ) + + +def _fake_anchor_sql_execute( + row_pool: list[dict[str, Any]], subject_lat: float, subject_lon: float +) -> Any: + """`db.execute` side_effect faking real ST_DWithin filtering for the Tier A + anchor SQL only. Identifies that query by its distinctive bound params + (`house_re` + `street_like`, unique to Tier A in the whole module). + + If the query ALSO binds `lat`/`lon`/`radius` (post-#2581), computes the + real haversine distance and returns rows from `row_pool` ONLY if within + `radius` of the bound subject `lat`/`lon` — exactly what Postgres' + ST_DWithin would decide. If those params are ABSENT (pre-#2581 — the SQL + had no geo predicate at all), returns `row_pool` UNCONDITIONALLY — this is + the faithful old-code behaviour (matched by address string alone, + regardless of distance), NOT an error: the fake must reproduce the actual + bug for the "prove it fails on old code" check to be meaningful, rather + than accidentally passing via an unrelated KeyError caught by + `_fetch_anchor_comps`'s own try/except. + + Everything else (Tier C anchor, IMV anchor, etc.) degrades to the same + empty-result default a bare, unconfigured `MagicMock()` gives. + """ + + def _side_effect(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + params = params or {} + if "house_re" in params and "street_like" in params: + if "lat" in params and "lon" in params and "radius" in params: + dist = _haversine_m(params["lat"], params["lon"], subject_lat, subject_lon) + rows = row_pool if dist <= params["radius"] else [] + else: + rows = row_pool + result.mappings.return_value.all.return_value = rows + else: + result.mappings.return_value.all.return_value = [] + return result + + return _side_effect + + +def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None: + """#2581: Серов «ул. Ленина, 5» must NOT anchor on an EKB «ул. Ленина, 5» listing. + + Pre-fix this would have returned tier='A' from the EKB listing (~186k + ₽/м²) and, critically, the `anchor_tier is None` gate on the + deals-headline-fallback (_price_from_inputs, #oblast-D) would then have + BLOCKED the honest deal-corridor headline too — Серов would surface the + EKB-leaked ~186k figure, never even reaching the deals-fallback path. + """ + dkp_raw = { + "count": 12, + "low_ppm2": 25_000, + "median_ppm2": 30_000, + "high_ppm2": 38_000, + "period_months": 12, + } + db = MagicMock() + # >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active + # listings are EKB) and necessary for Tier A to actually fire pre-fix. + db.execute.side_effect = _fake_anchor_sql_execute( + _ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]), + subject_lat=56.838, + subject_lon=60.595, + ) + geo = _serov_geo() + payload = _serov_payload() + + async def _run() -> Any: + from app.services.estimator import estimate_quality + + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + est = anyio.run(_run) + + assert est.median_price_per_m2 == 30_000, ( + f"headline={est.median_price_per_m2} must come from the honest deal " + "corridor (30_000), not an EKB-leaked Tier A anchor (~186k) — the " + "cross-city street-name collision must be rejected by ST_DWithin" + ) + assert est.median_price_per_m2 < 100_000, "must NOT be EKB-leaked (~186k)" + assert est.n_analogs == 0 + assert est.confidence == "low" + + +def test_ekb_anchor_still_works_with_real_same_city_comps() -> None: + """#2581 control: EKB same-building anchor must keep working post-fix. + + Multiple EKB listings on the subject's own street/house, all within the + ST_DWithin radius of the subject's own coordinates, must still form a + Tier A anchor — proving the geo-bound only rejects genuinely distant + (cross-city) collisions, not legitimate same-building EKB matches. + """ + from app.schemas.trade_in import TradeInEstimateInput + from app.services.geocoder import GeocodeResult + + subject_lat, subject_lon = 56.838, 60.595 + comps = _ekb_lenina_pool([140_000.0, 145_000.0, 150_000.0, 148_000.0]) + + db = MagicMock() + db.execute.side_effect = _fake_anchor_sql_execute( + comps, subject_lat=subject_lat, subject_lon=subject_lon + ) + + geo = GeocodeResult( + lat=subject_lat, + lon=subject_lon, + full_address="Свердловская обл., Екатеринбург, ул. Ленина, 5", + provider="nominatim", + ) + payload = TradeInEstimateInput( + address="Екатеринбург, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=16 + ) + + async def _run() -> Any: + from app.services.estimator import estimate_quality + + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + est = anyio.run(_run) + + # Same-building anchor engaged (EKB doesn't degrade): headline built from + # the 4 same-building comps, not left n/a / not routed through deals. + assert est.n_analogs == 4 + assert 138_000 <= est.median_price_per_m2 <= 152_000 diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4492cbad..4564c412 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -1,7 +1,7 @@ """Unit tests for cross-source physical dedup of analogs (#2087 H4). Проверяет: - - физический ключ (_phys_dedup_key) — что образует / не образует; + - композиты физического ключа (_lot_dedup_components) — что образуют / не образуют; - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя (свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично; - выбор представителя детерминирован (freshest scraped_at). @@ -21,6 +21,16 @@ import pytest from app.services import estimator +def _street_token(addr: str | None) -> str: + """Уличный токен из ЖИВОЙ функции разбора адреса. + + #2674: раньше тут звался `estimator._extract_street_token` — однострочная обёртка, + которую прод не вызывал ни разу. Обёртку убрали; хелпер живёт в тестах, где ему и + место, а проверяется ровно тот `_parse_street_house`, что крутится в проде. + """ + return estimator._parse_street_house(addr)[0] + + def _lot( *, source: str, @@ -48,45 +58,56 @@ def _lot( # --------------------------------------------------------------------------- # -# _phys_dedup_key +# _lot_dedup_components — композиты физического ключа +# +# #2674: раньше эти пять тестов дёргали `estimator._phys_dedup_key` — обёртку без +# единого прод-вызова, которая возвращала `cad_key or street_key`. Прод так ключ НЕ +# строит: _union_find_phys_dedup держит ОБА композита и сливает по любому совпадению +# (с guard'ами на разные кадастры / разные номера домов). Обёртку убрали, тесты +# переведены на живую _lot_dedup_components → (cad_s, house_no, cad_key, street_key). # --------------------------------------------------------------------------- # def test_phys_key_prefers_cadastral_over_address() -> None: lot = _lot(source="avito", address="ул. Ленина 5", cad="66:41:0601025:94") - key = estimator._phys_dedup_key(lot) - assert key is not None - assert key[0] == "66:41:0601025:94" # building = cad, не address + _cad_s, _house, cad_key, street_key = estimator._lot_dedup_components(lot) + assert cad_key is not None + assert cad_key[0] == "66:41:0601025:94" # building = cad, не address + # Street-композит тоже строится — прод сливает по ЛЮБОМУ из двух, не по одному. + assert street_key is not None and street_key[0] == "ленина" def test_phys_key_normalizes_address_when_no_cad() -> None: # Тот же адрес, разное форматирование (регистр/пунктуация/пробелы) — # punctuation-only нормализация схлопывает их к одному building-компоненту. - a = estimator._phys_dedup_key(_lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5")) - b = estimator._phys_dedup_key(_lot(source="cian", address="екатеринбург ул ленина д 5")) - assert a is not None and b is not None - assert a[0] == b[0] + a = estimator._lot_dedup_components( + _lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5") + ) + b = estimator._lot_dedup_components(_lot(source="cian", address="екатеринбург ул ленина д 5")) + assert a[3] is not None and b[3] is not None + assert a[3][0] == b[3][0] def test_phys_key_different_streets_do_not_merge() -> None: # Токенное отличие (разная улица) НЕ должно сливаться — защита от ложного merge. - a = estimator._phys_dedup_key(_lot(source="avito", address="ул. Ленина 5")) - b = estimator._phys_dedup_key(_lot(source="cian", address="ул. Мира 5")) - assert a is not None and b is not None - assert a[0] != b[0] + a = estimator._lot_dedup_components(_lot(source="avito", address="ул. Ленина 5")) + b = estimator._lot_dedup_components(_lot(source="cian", address="ул. Мира 5")) + assert a[3] is not None and b[3] is not None + assert a[3][0] != b[3][0] def test_phys_key_none_without_area_or_price() -> None: - assert estimator._phys_dedup_key(_lot(source="avito", area=0)) is None - assert estimator._phys_dedup_key(_lot(source="avito", price=0)) is None + for lot in (_lot(source="avito", area=0), _lot(source="avito", price=0)): + _cad_s, _house, cad_key, street_key = estimator._lot_dedup_components(lot) + assert cad_key is None and street_key is None def test_phys_key_area_bucket_tolerates_rounding() -> None: # 66.9 и 67.0 → один и тот же area_bucket (round → 67) - a = estimator._phys_dedup_key(_lot(source="avito", area=66.9)) - b = estimator._phys_dedup_key(_lot(source="cian", area=67.0)) - assert a is not None and b is not None - assert a[2] == b[2] == 67 + a = estimator._lot_dedup_components(_lot(source="avito", area=66.9)) + b = estimator._lot_dedup_components(_lot(source="cian", area=67.0)) + assert a[3] is not None and b[3] is not None + assert a[3][2] == b[3][2] == 67 # --------------------------------------------------------------------------- # @@ -188,20 +209,20 @@ def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> def test_extract_street_token_across_source_formats() -> None: # Три РЕАЛЬНЫХ прод-формата одного дома (ул. Сыромолотова) → один токен. - cian = estimator._extract_street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") - domklik = estimator._extract_street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") - yandex = estimator._extract_street_token("Екатеринбург, улица Сыромолотова, 11") + cian = _street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") + domklik = _street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") + yandex = _street_token("Екатеринбург, улица Сыромолотова, 11") assert cian == domklik == yandex == "сыромолотова" def test_extract_street_token_strips_house_and_noise() -> None: - assert estimator._extract_street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" - assert estimator._extract_street_token("проспект Космонавтов 15") == "космонавтов" - assert estimator._extract_street_token("пер. Банковский, 8а") == "банковский" + assert _street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" + assert _street_token("проспект Космонавтов 15") == "космонавтов" + assert _street_token("пер. Банковский, 8а") == "банковский" # Нет типа улицы → распознать нельзя → пусто (безопасный fallback, лот уникален). - assert estimator._extract_street_token("Екатеринбург, ЖК Дружба") == "" - assert estimator._extract_street_token("") == "" - assert estimator._extract_street_token(None) == "" + assert _street_token("Екатеринбург, ЖК Дружба") == "" + assert _street_token("") == "" + assert _street_token(None) == "" def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: @@ -325,8 +346,8 @@ def test_parse_numbered_streets_and_house_no() -> None: def test_extract_street_token_numbered_not_degraded() -> None: # Ведущая цифра нумерованной улицы разрешена, номер дома — нет. - assert estimator._extract_street_token("улица 8 Марта, 46") == "8 марта" - assert estimator._extract_street_token("ул. Белинского 86") == "белинского" + assert _street_token("улица 8 Марта, 46") == "8 марта" + assert _street_token("ул. Белинского 86") == "белинского" def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None: @@ -458,9 +479,9 @@ def test_parse_street_house_branch1_wins_over_mkr_noise() -> None: def test_extract_street_token_extended_same_token_across_formats() -> None: # Все три source-формата дома «Рассветная» → один и тот же токен → склейка. - domklik = estimator._extract_street_token("Рассветная улица, 6 к1") - avito = estimator._extract_street_token("Рассветная ул.,6к1") - cian = estimator._extract_street_token("улица Рассветная, 6к1") + domklik = _street_token("Рассветная улица, 6 к1") + avito = _street_token("Рассветная ул.,6к1") + cian = _street_token("улица Рассветная, 6к1") assert domklik == avito == cian == "рассветная" diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index fd8c0bcd..51e8a675 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -3,7 +3,8 @@ Two layers: 1. `_get_asking_sold_ratio` lookup helper (DB mocked): - - bucket = min(max(rooms or 0, 0), 4) — clamping + None handling + - bucket = area_bucket(area_m2) when area_m2 known (#2620-2), else + min(max(rooms or 0, 0), 4) — rooms-fallback + clamping + None handling - per-rooms row hit returns (ratio, basis) - per-rooms miss falls back to the global rooms_bucket=-1 row - empty / missing table → (None, None), never raises (graceful) @@ -72,6 +73,36 @@ def test_bucket_clamping() -> None: ), f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" +def test_bucket_keyed_by_area_when_area_known() -> None: + """#2620-2: bucket = area_bucket(area_m2), NOT rooms, when area_m2 is given. + + Root cause moved from calc (ask_side, fixed by #2620) to apply (this function) if the + lookup still keyed by listings/payload.rooms: расчёт бакетирует по площади, применение + должно совпадать. A "4-room" 70m² flat area-buckets to 3 (62<=70<85) — deep-review prod + measurement (2026-08): 310/1038 (29.9%) historical requests would land in a different + ratio bucket under the old rooms-key vs the area-key. + """ + from app.services.estimator import _get_asking_sold_ratio + + _clear_ratio_cache() + db = _db_returning([_FakeRow(0.83, "per_rooms")]) + _get_asking_sold_ratio(db, rooms=4, area_m2=70.0) + bind = db.execute.call_args_list[0].args[1] + assert bind["b"] == 3, f"70m2 (rooms=4 claimed) should bucket to 3 by area, got {bind['b']}" + + +def test_bucket_falls_back_to_rooms_when_area_missing() -> None: + """area_m2 unknown (None/0) → rooms-based bucket fallback, min(max(rooms,0),4).""" + from app.services.estimator import _get_asking_sold_ratio + + for area_m2 in (None, 0, 0.0): + _clear_ratio_cache() + db = _db_returning([_FakeRow(0.83, "per_rooms")]) + _get_asking_sold_ratio(db, rooms=4, area_m2=area_m2) + bind = db.execute.call_args_list[0].args[1] + assert bind["b"] == 4, f"area_m2={area_m2!r} should fall back to rooms bucket 4" + + def test_per_rooms_hit_returns_ratio_basis() -> None: """Per-rooms row present → returned directly, no fallback query.""" from app.services.estimator import _get_asking_sold_ratio @@ -153,11 +184,14 @@ def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, An } -# Three fixed analogs → deterministic median ppm2 = 150_000 (< 5 ⇒ no outlier drop). +# #oblast-E: 5 fixed analogs (>= HEADLINE_LISTINGS_MIN_N) → deterministic +# median ppm2 = 150_000 (symmetric around it, < outlier-drop threshold). _ANALOGS: list[dict[str, Any]] = [ - _make_listing(price_per_m2=140_000.0), + _make_listing(price_per_m2=135_000.0), + _make_listing(price_per_m2=145_000.0), _make_listing(price_per_m2=150_000.0), - _make_listing(price_per_m2=160_000.0), + _make_listing(price_per_m2=155_000.0), + _make_listing(price_per_m2=165_000.0), ] diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index 372f7cec..ca8842ac 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -47,10 +47,15 @@ def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, An } +# #oblast-E: 5 analogs (>= HEADLINE_LISTINGS_MIN_N) so the new headline +# sufficiency gate doesn't suppress the median before this test's target +# mechanic (expected-sold clamp) runs. Median stays 270_000. _ANALOGS: list[dict[str, Any]] = [ + _make_listing(price_per_m2=250_000.0), _make_listing(price_per_m2=260_000.0), _make_listing(price_per_m2=270_000.0), _make_listing(price_per_m2=280_000.0), + _make_listing(price_per_m2=290_000.0), ] diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py new file mode 100644 index 00000000..3ae13ef7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -0,0 +1,373 @@ +"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02). + +Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings → +headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring +street in the same town swung ±66% on 1-2 different random listings. Каменск- +Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match +either, with no honest refusal surfaced. Первоуральск (0 listings) already fell +back to the (pre-existing) ДКП deals-headline fallback correctly — this gate +routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME, +already-tested path instead of trusting a 1-4-lot median as the headline. + +Two layers: + 1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) — + boundary behaviour of the gate itself. + 2. `estimate_quality` integration tests — proves the money-path invariants + that matter to a caller: literal 0 never leaks as a "confident" price, + display `analogs` cards never outnumber what `n_analogs` claims, and the + explanation text describes what actually happened (not a stock "аналогов + не найдено" when some WERE found, just too few). +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator +from app.services.estimator import HEADLINE_LISTINGS_MIN_N, _price_from_inputs +from app.services.geocoder import GeocodeResult + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 1 — `_price_from_inputs` direct unit tests +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=59.604, + lon=60.577, + full_address="Свердловская обл., Серов, ул. Ленина, 5", + provider="nominatim", + ) + + +def _lot(ppm2: float, address: str = "ул. Ленина, 5", source: str = "avito") -> dict[str, Any]: + return {"price_per_m2": ppm2, "address": address, "source": source} + + +def _lots(prices: list[float]) -> list[dict[str, Any]]: + return [_lot(p, address=f"ул. Ленина, {i + 5}") for i, p in enumerate(prices)] + + +def _call( + *, + listings: list[dict[str, Any]], + area_m2: float = 45.0, + rooms: int | None = 2, + dkp_raw: dict[str, Any] | None = None, + anchor_comps: list[dict[str, Any]] | None = None, + anchor_tier_fetched: str | None = None, +) -> estimator.PricingResult: + def ratio_resolver(_appm2: float | None) -> tuple[float | None, str | None]: + return None, None + + return _price_from_inputs( + listings=listings, + area_m2=area_m2, + rooms=rooms, + repair_state=None, + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=anchor_comps or [], + anchor_tier_fetched=anchor_tier_fetched, + dkp_raw=dkp_raw, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=ratio_resolver, + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + + +def test_threshold_is_five_not_lower() -> None: + """The chosen sufficiency floor — see estimator.py module docstring (#oblast-E) + for the data-driven justification (n=3 live-repro'd −36%, n=5 matches the + existing MIN_ANALOGS_TIER_0 "enough to trust" convention).""" + assert HEADLINE_LISTINGS_MIN_N == 5 + + +def test_four_listings_below_threshold_suppressed_no_fallback() -> None: + """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state, + NOT the naive median of 4 listings.""" + pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0])) + assert pr.median_ppm2 == 0.0 + assert pr.median_price == 0 + assert pr.n_analogs == 0 + assert pr.range_low == 0 + assert pr.range_high == 0 + + +def test_five_listings_at_threshold_not_suppressed() -> None: + """n=5 (== threshold) → the real listings median is trusted as the headline.""" + pr = _call(listings=_lots([200_000.0, 205_000.0, 210_000.0, 215_000.0, 220_000.0])) + assert pr.median_ppm2 == 210_000.0 + assert pr.n_analogs == 5 + assert pr.median_price == round(210_000.0 * 45.0) + + +def test_one_listing_below_threshold_suppressed() -> None: + """n=1 — the sharpest form of the Серов bug (a single random lot deciding + the whole headline) — must be suppressed exactly like n=4.""" + pr = _call(listings=_lots([200_000.0])) + assert pr.median_ppm2 == 0.0 + assert pr.n_analogs == 0 + + +def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: + """n=3 listings (thin) + a usable ДКП corridor → headline comes from the + deal corridor median, NOT the 3-listing median (live Серов repro: 3 + listings gave 42 391 vs the honest ДКП-based ~54 126).""" + dkp_raw = { + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, + "period_months": 12, + } + pr = _call( + listings=_lots([42_391.0, 26_818.0, 75_058.0]), + dkp_raw=dkp_raw, + ) + assert pr.median_ppm2 == 65_957.0, ( + f"headline={pr.median_ppm2} must equal the ДКП corridor median, not the " + "noisy 3-listing median (42 391 area)" + ) + assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline" + assert pr.confidence == "low" + + +def test_thin_sample_with_insufficient_deals_stays_zero() -> None: + """n=3 listings (thin) + a ДКП corridor that is ITSELF too thin + (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero, + not a fabricated number from either side.""" + dkp_raw = { + "count": 1, + "low_ppm2": 40_000, + "median_ppm2": 65_957, + "high_ppm2": 80_000, + "period_months": 12, + } + pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw) + assert pr.median_ppm2 == 0.0 + assert pr.median_price == 0 + assert pr.n_analogs == 0 + + +def test_thin_sample_explanation_is_honest_about_count() -> None: + """The explanation for a thin-but-nonzero sample must say HOW MANY listings + were found (not the generic 'ничего не найдено' text used for a genuine + zero-listing case) — #4 in the task: explanation must match reality.""" + pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2 + assert pr.explanation is not None + assert "2" in pr.explanation + assert "недостаточно" in pr.explanation.lower() + # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found. + assert "не найдено аналогов" not in pr.explanation.lower() + + +def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None: + """#4: once the ДКП fallback fires for a thin (not zero) sample, the + explanation must not falsely claim 'рядом нет объявлений' — some WERE + found, just not enough to trust.""" + dkp_raw = { + "count": 20, + "low_ppm2": 40_000, + "median_ppm2": 60_000, + "high_ppm2": 80_000, + "period_months": 12, + } + pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw) + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + + +def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None: + """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs) + without clearing `listings_clean` itself — the same-building anchor's own + ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell + "genuinely zero nearby listings" from "some nearby, just too few to trust + as headline", and conflating the two was caught regressing + test_estimator_split_corridor_1871.py during this change.""" + pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0])) + assert pr.n_analogs == 0 + assert len(pr.listings_clean) == 3 + assert pr.listings_headline_thin_n == 3 + + +def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None: + """Sanity/control: once n reaches the threshold, the thin-marker stays 0 — + downstream (estimate_quality) must not treat a healthy sample as thin.""" + pr = _call(listings=_lots([200_000.0, 205_000.0, 210_000.0, 215_000.0, 220_000.0])) + assert pr.listings_headline_thin_n == 0 + + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) +# ───────────────────────────────────────────────────────────────────────────── + + +def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 45.0) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 59.604, + "lon": 60.577, + "rooms": 2, + "area_m2": area_m2, + "floor": 5, + "total_floors": 9, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _serov_payload() -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="Серов, ул. Ленина, 5", + area_m2=45.0, + rooms=2, + floor=5, + total_floors=9, + city_hint="Серов", + ) + + +def _run_estimate( + *, + analogs: list[dict[str, Any]], + dkp_raw: dict[str, Any] | None, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _serov_payload() + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "W"), + ), + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None: + """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor — + median_price_rub must be 0 AND insufficient_data must be True TOGETHER + (the AggregatedEstimate.insufficient_data computed_field invariant that + stops a literal 0 ₽ reaching the user as a confident number).""" + analogs = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + est = _run_estimate(analogs=analogs, dkp_raw=None) + assert est.median_price_rub == 0 + assert est.insufficient_data is True + assert est.n_analogs == 0 + assert est.confidence == "low" + + +def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None: + """The 2 thin listings must NOT be surfaced as `analogs` display cards while + n_analogs reports 0 — that would be the same dishonesty (confident-looking + UI) this whole gate exists to remove.""" + analogs = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + est = _run_estimate(analogs=analogs, dkp_raw=None) + assert est.n_analogs == 0 + assert est.analogs == [] + + +def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: + """Live Серов repro (n=3 scraped listings, wide ДКП corridor available): + headline must come from the deal corridor, not the noisy 3-listing median, + and the estimate must be honestly non-'insufficient' (a real number, low + confidence, deals-sourced).""" + analogs = [ + _make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"), + _make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"), + _make_listing(price_per_m2=75_058.0, address="ул. Льва Толстого, 34"), + ] + dkp_raw = { + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, + "period_months": 12, + } + est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw) + assert est.median_price_per_m2 == 65_957 + assert est.insufficient_data is False + assert est.n_analogs == 0 + assert est.confidence == "low" + assert est.confidence_explanation is not None + assert "сделкам росреестра" in est.confidence_explanation.lower() + + +def test_e2e_sufficient_five_analogs_unaffected_control() -> None: + """Control (mirrors the Екатеринбург prod check in the PR): a sample that + clears the threshold is priced exactly as before — headline is the real + listings median, all 5 analogs counted.""" + analogs = [ + _make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 9"), + _make_listing(price_per_m2=215_000.0, address="ул. Ленина, 11"), + _make_listing(price_per_m2=225_000.0, address="ул. Ленина, 13"), + ] + est = _run_estimate(analogs=analogs, dkp_raw=None) + assert est.median_price_per_m2 == 210_000 + assert est.n_analogs == 5 + assert est.insufficient_data is False diff --git a/tradein-mvp/backend/tests/test_estimator_implausible_year.py b/tradein-mvp/backend/tests/test_estimator_implausible_year.py new file mode 100644 index 00000000..5268003c --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_implausible_year.py @@ -0,0 +1,187 @@ +"""Guard against implausible year_built poisoning the hedonic correction (Mera-audit 2026-08-02). + +house_metadata (OSM/кадастр, best-effort enrichment) и +TradeInEstimateInput.year_built (payload, схема допускает ge=1800) могут +отдать явно ошибочный год постройки МКД — прод-инцидент: house_metadata +year_built=1829 для обычной вторички (см. vault fixes). + +Без валидации этот год уходит в хедонический year+area фактор +(_price_from_inputs, #2002), который экстраполирует regression fit далеко за +пределы обучающей выборки (COHORTS не определяет когорту раньше 1955 — см. +estimator.py) и упирается в нижний кламп estimate_hedonic_factor_min=0.75 — +выкупная цена режется на фиксированные −25% без физического смысла. + +_sanitize_build_year() отсекает год вне +[MIN_PLAUSIBLE_BUILD_YEAR, текущий год + MAX_PLAUSIBLE_BUILD_YEAR_LEAD] на +входе, трактуя его как «неизвестен» (None) — хедонический year-term +становится нейтральным (эквивалент year=2000, см. test_estimator_hedonic.py +::test_target_year_none_is_neutral), а не клампится к произвольной границе. + +NOTE: importing app.services.estimator pulls app.core.config.Settings which +requires DATABASE_URL. Set it BEFORE importing app modules (см. паттерн +test_estimator_hedonic.py). +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator +from app.services.geocoder import GeocodeResult + +# ── _sanitize_build_year: unit-level ──────────────────────────────────────── + + +def test_implausible_low_year_dropped_to_none(caplog: pytest.LogCaptureFixture) -> None: + """год=1829 (прод-инцидент house_metadata) → трактуется как отсутствующий.""" + with caplog.at_level("WARNING"): + result = estimator._sanitize_build_year(1829, house_id=42, address="ул. Тестовая, 1") + assert result is None + assert any("1829" in r.message for r in caplog.records) + assert any("42" in r.message for r in caplog.records) + + +def test_plausible_year_unchanged() -> None: + """год=1960 (хрущёвка) — валиден, работает как раньше (без изменений).""" + assert estimator._sanitize_build_year(1960) == 1960 + + +def test_future_year_beyond_lead_dropped() -> None: + """год = текущий+10 (далеко за допуском для строек) → отбрасывается.""" + future_year = datetime.now(UTC).year + 10 + assert estimator._sanitize_build_year(future_year) is None + + +def test_near_future_year_within_lead_kept() -> None: + """год = текущий + LEAD (граница допуска для строек) — остаётся валидным.""" + near_future = datetime.now(UTC).year + estimator.MAX_PLAUSIBLE_BUILD_YEAR_LEAD + assert estimator._sanitize_build_year(near_future) == near_future + + +def test_none_year_unchanged() -> None: + """Отсутствие года — поведение НЕ меняется (уже было честным «не знаем»).""" + assert estimator._sanitize_build_year(None) is None + + +def test_boundary_year_min_plausible_kept() -> None: + """MIN_PLAUSIBLE_BUILD_YEAR сам — валиден (inclusive).""" + year = estimator.MIN_PLAUSIBLE_BUILD_YEAR + assert estimator._sanitize_build_year(year) == year + + +def test_boundary_year_below_min_dropped() -> None: + """MIN_PLAUSIBLE_BUILD_YEAR - 1 — уже невалиден.""" + assert estimator._sanitize_build_year(estimator.MIN_PLAUSIBLE_BUILD_YEAR - 1) is None + + +# ── price impact via _price_from_inputs (hermetic, no DB) — #1966-стиль ──── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.597, + full_address="ул. Тестовая, 1", + provider="nominatim", + confidence="approximate", + ) + + +def _lots(ppm2: float, n: int = 7) -> list[dict]: + """n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2.""" + return [ + {"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"} + for i in range(n) + ] + + +def _price(*, target_year: int | None, area_m2: float = 50.0) -> estimator.PricingResult: + """Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio. + + Зеркалит helper из test_estimator_hedonic.py — прогоняет ровно тот же + вызов _price_from_inputs, который estimate_quality делает после + _sanitize_build_year(target_year, ...). + """ + + def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]: + return 0.85, "per_rooms" + + return estimator._price_from_inputs( + listings=_lots(100_000.0), + area_m2=area_m2, + rooms=2, + repair_state=None, + floor=5, + total_floors=10, + target_year=target_year, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=ratio_resolver, + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + + +def test_price_not_cut_after_sanitizing_1829(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод-репро (Mera-audit 2026-08-02): год=1829 без guard'а клампит хедонический фактор в + пол estimate_hedonic_factor_min=0.75 (−25% к цене). После sanitize + (estimate_quality прогоняет target_year через _sanitize_build_year ДО + _price_from_inputs) год трактуется как отсутствующий — фактор нейтрален, + цена НЕ порезана. + """ + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + + # "Было бы" без фикса: 1829 идёт в хедонику напрямую. + unfixed = _price(target_year=1829) + # "Стало" с фиксом: estimate_quality сначала санитайзит год. + sanitized_year = estimator._sanitize_build_year(1829) + assert sanitized_year is None + fixed = _price(target_year=sanitized_year) + + assert unfixed.expected_sold_price is not None + assert fixed.expected_sold_price is not None + + ratio_only = round(unfixed.median_price * 0.85) + factor_before = unfixed.expected_sold_price / ratio_only + factor_after = fixed.expected_sold_price / ratio_only + + # До фикса — кламп ровно в пол (фиксированная −25% недоплата). + assert factor_before == pytest.approx(estimator.settings.estimate_hedonic_factor_min, abs=1e-3) + # После фикса — год «неизвестен», фактор около нейтрали (НЕ 0.75). + assert factor_after > 0.95 + assert fixed.expected_sold_price > unfixed.expected_sold_price + + # Совпадает байт-в-байт с явным "год не указан" (test_target_year_none_is_neutral). + none_year = _price(target_year=None) + assert fixed.expected_sold_price == none_year.expected_sold_price + assert fixed.expected_sold_per_m2 == none_year.expected_sold_per_m2 + + +def test_year_1960_hedonic_unaffected_by_guard(monkeypatch: pytest.MonkeyPatch) -> None: + """год=1960 (валидная хрущёвка) — guard не меняет хедоническую поправку.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + sanitized_year = estimator._sanitize_build_year(1960) + assert sanitized_year == 1960 + + before = _price(target_year=1960) + after = _price(target_year=sanitized_year) + + assert before.expected_sold_price == after.expected_sold_price + assert before.expected_sold_per_m2 == after.expected_sold_per_m2 diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 9d56d357..77bf6a57 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -320,10 +320,15 @@ def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, An } +# #oblast-E: 5 analogs (>= HEADLINE_LISTINGS_MIN_N) so the new headline +# sufficiency gate doesn't suppress the median before this test's target +# mechanic (IMV blend) runs. Median stays 150_000. _BLEND_ANALOGS: list[dict[str, Any]] = [ - _make_listing(price_per_m2=140_000.0), + _make_listing(price_per_m2=135_000.0), + _make_listing(price_per_m2=145_000.0), _make_listing(price_per_m2=150_000.0), - _make_listing(price_per_m2=160_000.0), + _make_listing(price_per_m2=155_000.0), + _make_listing(price_per_m2=165_000.0), ] diff --git a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py index af2cf793..d73a9364 100644 --- a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py +++ b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py @@ -87,15 +87,22 @@ def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any def test_n_analogs_counts_only_priced_radius_analogs() -> None: - """Radius-путь: 3 аналога с ценой + 2 без цены (price_per_m2=None) пережили - outlier-фильтр → n_analogs == 3 (число ВНЁСШИХ цену), а не 5 (всего). + """Radius-путь: 5 аналогов с ценой + 2 без цены (price_per_m2=None) пережили + outlier-фильтр → n_analogs == 5 (число ВНЁСШИХ цену), а не 7 (всего). - До фикса n_analogs = len(listings_clean) = 5 → «Найдено 5 аналогов» вводило в - заблуждение (медиана построена лишь по 3).""" + До фикса n_analogs = len(listings_clean) = 7 → «Найдено 7 аналогов» вводило в + заблуждение (медиана построена лишь по 5). + + #oblast-E: priced count is 5 (>= HEADLINE_LISTINGS_MIN_N) — below that, the + new headline sufficiency gate would suppress the median before this test's + target invariant (priced-only counting) is even reachable; see + test_estimator_headline_sufficiency.py for that gate's own tests.""" radius = [ - _listing(price_per_m2=200_000.0), + _listing(price_per_m2=195_000.0), + _listing(price_per_m2=205_000.0), _listing(price_per_m2=210_000.0), - _listing(price_per_m2=220_000.0), + _listing(price_per_m2=215_000.0), + _listing(price_per_m2=225_000.0), _listing(price_per_m2=None), _listing(price_per_m2=None), ] @@ -105,8 +112,8 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None: radius_analogs=radius, payload=_h._make_payload(area=60.0, rooms=2), ) - # n_analogs = число PRICED аналогов (3), НЕ len(listings_clean) (5). - assert est.n_analogs == 3 + # n_analogs = число PRICED аналогов (5), НЕ len(listings_clean) (7). + assert est.n_analogs == 5 # Медиана построена → headline ненулевой (sanity: фикс не обнулил оценку). assert est.median_price_rub > 0 diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index d9ad61d7..4b8d7a72 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -305,6 +305,13 @@ def _make_listing_qi( price_per_m2: float = _BASE_PPM2, area_m2: float = _AREA, building_cadastral_number: str | None = None, + # #oblast-E: distinct floor per synthetic lot (default matches the old + # hardcoded 4) — _dedup_cross_source's physical key is + # (street/cadnum, floor, area_bucket, price_bucket); with every fixture lot + # sharing address/price/area, an unvaried floor made 5+ synthetic lots + # collapse into 1 via cross-source dedup, defeating the >= HEADLINE_LISTINGS_MIN_N + # gate below floor=4 is a no-op for all pre-existing single-lot call sites. + floor: int = 4, ) -> dict[str, Any]: from datetime import UTC, datetime @@ -317,7 +324,7 @@ def _make_listing_qi( "lon": 60.595, "rooms": 1, "area_m2": area_m2, - "floor": 4, + "floor": floor, "total_floors": 16, "price_rub": price_rub, "price_per_m2": price_per_m2, @@ -458,8 +465,9 @@ _ANALOGS_OTHER_QUARTER = [ _make_listing_qi( price_per_m2=_BASE_PPM2, building_cadastral_number=f"{_OTHER_QUARTER}:100", + floor=4 + i, ) - for _ in range(3) + for i in range(5) ] @@ -477,7 +485,8 @@ def test_quarter_index_correction_applied() -> None: # Чтобы увидеть ненулевую коррекцию, делаем аналоги БЕЗ кадастрового номера # → avg_analog_index = 1.0 → factor = 1.2. analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] est = _run_estimate_qi( analogs=analogs_no_cadnum, @@ -501,12 +510,16 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None: # 4 аналога в target квартале, 1 в другом → ratio = 4/5 = 0.8 > 0.6 → skip target_cadnum = f"{_TARGET_QUARTER}:100" other_cadnum = f"{_OTHER_QUARTER}:100" + # #oblast-E: distinct floor per lot — otherwise all 5 share the same + # (street, floor, area_bucket, price_bucket) physical key and + # _dedup_cross_source collapses them into ONE lot (defeats both this + # test's 4-vs-1 ratio AND the >= HEADLINE_LISTINGS_MIN_N sufficiency gate). analogs = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum), - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum), - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum), - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum), - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=other_cadnum), + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum, floor=1), + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum, floor=2), + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum, floor=3), + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=target_cadnum, floor=4), + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=other_cadnum, floor=5), ] base_median = round(_BASE_PPM2 * _AREA) est = _run_estimate_qi( @@ -526,7 +539,8 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None: def test_sparse_fallback_no_row_noop() -> None: """_lookup_quarter_index вернул None → no-op, медиана не меняется.""" analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] base_median = round(_BASE_PPM2 * _AREA) est = _run_estimate_qi( @@ -545,7 +559,8 @@ def test_sparse_fallback_no_row_noop() -> None: def test_bimodal_guard_skips_high_index_small_n() -> None: """Bimodal guard: price_index=3.5, n_deals=20 → no-op.""" analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] base_median = round(_BASE_PPM2 * _AREA) est = _run_estimate_qi( @@ -563,7 +578,8 @@ def test_bimodal_guard_allows_high_index_large_n() -> None: Медиана меняется (guard не блокирует), но масштабируется на 1.8, не 2.5. """ analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] base_median = round(_BASE_PPM2 * _AREA) est = _run_estimate_qi( @@ -597,7 +613,7 @@ def test_guard1_anchor_tier_prevents_correction() -> None: fake_comps = [ _make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None) - for _ in range(3) + for _ in range(5) ] fake_anchor = { "anchor_ppm2": _BASE_PPM2 * 1.5, @@ -616,7 +632,8 @@ def test_guard1_anchor_tier_prevents_correction() -> None: } analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] async def _run(): @@ -696,7 +713,8 @@ def test_guard1b_imv_blend_prevents_correction() -> None: dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350") analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] # IMV anchor сильно выше медианы → blend сработает @@ -778,7 +796,8 @@ def test_guard1b_imv_anchor_below_blend_threshold_prevents_correction() -> None: dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350") analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) + _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) + for i in range(5) ] # IMV anchor НИЖЕ blend-порога: base_median = 6_000_000, threshold=1.15 → порог 6.9М. diff --git a/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py b/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py index f7444f65..ffc31b5c 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_m_2044.py @@ -9,15 +9,22 @@ NOTE: importing app.services.estimator pulls app.core.config.Settings which requires DATABASE_URL. Set it BEFORE importing app modules. """ +from __future__ import annotations + import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +import anyio import pytest from pydantic import ValidationError from app.schemas.trade_in import TradeInEstimateInput from app.services import estimator +from app.services.geocoder import GeocodeResult def test_radius_m_defaults_to_none() -> None: @@ -57,5 +64,104 @@ def test_api_radius_expand_clamp_bounds() -> None: assert _clamp(9000) == 5000 +# ───────────────────────────────────────────────────────────────────────────── +# #2632 — AggregatedEstimate.search_radius_m: the ACTUAL radius listings were +# selected at (base vs. server-side fallback expansion), independent of what +# the caller/UI asked for. Pattern mirrors the full stub-patched estimate_quality +# harness in test_estimator_headline_sufficiency.py. +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.909, lon=59.960, full_address="Первоуральск, ул. Ленина, 5", provider="nominatim" + ) + + +def _make_listing(*, price_per_m2: float, address: str) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 56.909, + "lon": 59.960, + "rooms": 2, + "area_m2": 45.0, + "floor": 5, + "total_floors": 9, + "price_rub": price_per_m2 * 45.0, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _run_estimate_with_radius_tiers(*, base_n: int, fallback_n: int) -> Any: + """estimate_quality() with _fetch_analogs stubbed per-radius: returns + `base_n` listings at DEFAULT_RADIUS_M and `fallback_n` at FALLBACK_RADIUS_M — + lets a single test control whether the server-side widen branch fires.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = TradeInEstimateInput( + address="Первоуральск, ул. Ленина, 5", area_m2=45.0, rooms=2, floor=5, total_floors=9 + ) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + radius = kwargs.get("radius_m") + n = fallback_n if radius == estimator.FALLBACK_RADIUS_M else base_n + listings = [ + _make_listing(price_per_m2=200_000.0 + i * 1_000, address=f"д. {i}") for i in range(n) + ] + return listings, radius == estimator.FALLBACK_RADIUS_M, "W" + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", side_effect=_fetch_analogs_stub), + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_search_radius_m_reports_base_when_no_expansion_needed() -> None: + """Enough analogs already at the 1 km base radius → no fallback widen fires, + search_radius_m must report the base radius (1000 m), not just be absent.""" + est = _run_estimate_with_radius_tiers(base_n=5, fallback_n=20) + assert est.search_radius_m == estimator.DEFAULT_RADIUS_M + + +def test_search_radius_m_reports_fallback_when_server_expands() -> None: + """#2632 repro: base radius (1 km) starves (<5 listings) → server silently + widens to the 2 km fallback. The circle on the map must follow THIS radius, + not the user's original dropdown choice — search_radius_m must say 2000.""" + est = _run_estimate_with_radius_tiers(base_n=2, fallback_n=8) + assert est.search_radius_m == estimator.FALLBACK_RADIUS_M + + if __name__ == "__main__": # pragma: no cover raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/tradein-mvp/backend/tests/test_estimator_range_floor.py b/tradein-mvp/backend/tests/test_estimator_range_floor.py index 1801958c..7e769e52 100644 --- a/tradein-mvp/backend/tests/test_estimator_range_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_range_floor.py @@ -136,9 +136,18 @@ def test_floor_only_widens_never_shrinks() -> None: def test_single_analog_asking_range_gets_nonzero_width() -> None: - """n=1 analog: q1==q3==median → zero-width asking range → floored to ±12 %.""" - pr = _call(listings=[_lot(100_000)]) - assert pr.n_analogs == 1 + """Degenerate (identical-price) sample: q1==q3==median → zero-width asking + range → floored to ±12 %. + + #oblast-E: uses 5 identical-price lots (>= HEADLINE_LISTINGS_MIN_N) rather + than a literal single analog — below the new sufficiency threshold the + headline is suppressed entirely (see test_estimator_headline_sufficiency.py), + so a genuine n=1 no longer reaches this range-floor code at all. The + zero-width-IQR degenerate case this test targets is preserved identically + with identical-price lots. + """ + pr = _call(listings=_lots(100_000, n=5)) + assert pr.n_analogs == 5 point = pr.median_price # 100_000 × 50 = 5_000_000 assert point == 5_000_000 half = round(RANGE_MIN_HALFWIDTH_PCT * point) @@ -164,8 +173,10 @@ def test_wide_analog_range_not_floored() -> None: def test_expected_sold_range_floored_around_expected_point() -> None: - """Degenerate n=1 with a ratio: expected_sold range also gets ±12 % of its point.""" - pr = _call(listings=[_lot(100_000)], ratio=0.90) + """Degenerate identical-price sample with a ratio: expected_sold range also + gets ±12 % of its point (#oblast-E: 5 identical-price lots, see comment on + test_single_analog_asking_range_gets_nonzero_width above).""" + pr = _call(listings=_lots(100_000, n=5), ratio=0.90) assert pr.expected_sold_price is not None assert pr.expected_sold_range_low is not None assert pr.expected_sold_range_high is not None @@ -187,7 +198,7 @@ def test_ppm2_point_consistent_with_floored_range() -> None: not a ppm² range, so цена↔ppm² consistency means the point ppm² must equal median_price/area even after the range floor widens the rub band. """ - pr = _call(listings=[_lot(100_000)], area_m2=50.0) + pr = _call(listings=_lots(100_000, n=5), area_m2=50.0) assert pr.median_ppm2 == pr.median_price / 50.0 # Floor widened the rub range but did not touch the ppm² point. assert pr.median_ppm2 == 100_000.0 diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py index 478e1db2..0e9dab86 100644 --- a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py +++ b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py @@ -49,16 +49,23 @@ def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, An } +# #oblast-E: 5 items each (>= HEADLINE_LISTINGS_MIN_N) so the new headline +# sufficiency gate doesn't suppress the median before this test's target +# mechanic (ratio-tier resolution) runs. _ANALOGS_LOW: list[dict[str, Any]] = [ + _make_listing(price_per_m2=95_000.0), _make_listing(price_per_m2=100_000.0), - _make_listing(price_per_m2=110_000.0), _make_listing(price_per_m2=105_000.0), + _make_listing(price_per_m2=110_000.0), + _make_listing(price_per_m2=115_000.0), ] _ANALOGS_HIGH: list[dict[str, Any]] = [ + _make_listing(price_per_m2=295_000.0), _make_listing(price_per_m2=300_000.0), - _make_listing(price_per_m2=310_000.0), _make_listing(price_per_m2=305_000.0), + _make_listing(price_per_m2=310_000.0), + _make_listing(price_per_m2=315_000.0), ] @@ -100,8 +107,9 @@ def _run_estimate_with_ratio_spy( ratio_calls: list[Any] = [] - def _spy_ratio(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> Any: - ratio_calls.append({"rooms": rooms, "anchor_ppm2": anchor_ppm2}) + def _spy_ratio(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> Any: + # #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2. + ratio_calls.append({"rooms": rooms, "area_m2": area_m2, "anchor_ppm2": anchor_ppm2}) return ratio_return async def _run() -> Any: @@ -161,7 +169,8 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None: # _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k). captured_anchor_ppm2: list[float | None] = [] - def _spy(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> tuple: + def _spy(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> tuple: + # #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2. captured_anchor_ppm2.append(anchor_ppm2) return (0.78, "per_rooms") diff --git a/tradein-mvp/backend/tests/test_estimator_repair_coef.py b/tradein-mvp/backend/tests/test_estimator_repair_coef.py index 24ba4b32..83393181 100644 --- a/tradein-mvp/backend/tests/test_estimator_repair_coef.py +++ b/tradein-mvp/backend/tests/test_estimator_repair_coef.py @@ -52,11 +52,16 @@ def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, An } -# Three fixed analogs → deterministic median ppm2 = 150_000 (< 5 ⇒ no outlier drop). +# #oblast-E: 5 fixed analogs (>= HEADLINE_LISTINGS_MIN_N so the new headline +# sufficiency gate doesn't suppress the median before this test's target +# mechanic runs) → deterministic median ppm2 = 150_000 (symmetric around it, +# < outlier-drop threshold). _ANALOGS: list[dict[str, Any]] = [ - _make_listing(price_per_m2=140_000.0), + _make_listing(price_per_m2=135_000.0), + _make_listing(price_per_m2=145_000.0), _make_listing(price_per_m2=150_000.0), - _make_listing(price_per_m2=160_000.0), + _make_listing(price_per_m2=155_000.0), + _make_listing(price_per_m2=165_000.0), ] @@ -94,25 +99,29 @@ def _run_estimate(repair_state: str | None): async def _run(): with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.dadata_clean_address", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), # 3-tuple: (listings, fallback_used, analog_tier). Same analogs every call so # all fallback tiers are equivalent and the median is stable. - patch("app.services.estimator._fetch_analogs", - return_value=(list(_ANALOGS), False, "S")), + patch( + "app.services.estimator._fetch_analogs", return_value=(list(_ANALOGS), False, "S") + ), patch("app.services.estimator._fetch_deals", return_value=[]), - patch("app.services.estimator._get_or_fetch_imv_cached", - new=AsyncMock(return_value=None)), - patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=AsyncMock(return_value=None)), - patch("app.services.estimator.estimate_via_cian_valuation", - new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), # #648 S3: stub asking→sold lookup off so this test isolates the # repair coefficient (no sold-correction, no DB). - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): return await estimate_quality(payload, db) @@ -133,9 +142,9 @@ def test_excellent_to_needs_repair_ratio_matches_coef() -> None: actual_ratio = excellent.median_price_rub / needs_repair.median_price_rub # int() truncation on both medians ⇒ allow a small tolerance. - assert abs(actual_ratio - expected_ratio) < 0.005, ( - f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}" - ) + assert ( + abs(actual_ratio - expected_ratio) < 0.005 + ), f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}" def test_standard_is_baseline_noop() -> None: @@ -159,9 +168,7 @@ def test_none_repair_state_is_noop() -> None: expected_median = int(150_000.0 * 40.0) assert baseline.median_price_rub == expected_median - assert "скорректирована на состояние ремонта" not in ( - baseline.confidence_explanation or "" - ) + assert "скорректирована на состояние ремонта" not in (baseline.confidence_explanation or "") def test_repair_note_present_when_coef_differs() -> None: @@ -181,6 +188,4 @@ def test_repair_note_present_when_coef_differs() -> None: def test_standard_emits_no_repair_note() -> None: """Baseline standard (1.0) is a true no-op — no repair note appended.""" standard = _run_estimate("standard") - assert "скорректирована на состояние ремонта" not in ( - standard.confidence_explanation or "" - ) + assert "скорректирована на состояние ремонта" not in (standard.confidence_explanation or "") diff --git a/tradein-mvp/backend/tests/test_estimator_segment_multiplier.py b/tradein-mvp/backend/tests/test_estimator_segment_multiplier.py index 6b3c2ea3..4583793e 100644 --- a/tradein-mvp/backend/tests/test_estimator_segment_multiplier.py +++ b/tradein-mvp/backend/tests/test_estimator_segment_multiplier.py @@ -294,19 +294,23 @@ def test_flag_on_business_band_multiplies_point_and_range( def test_flag_on_order_multiplier_before_range_floor( monkeypatch: pytest.MonkeyPatch, ) -> None: - """n=1 degenerate range: multiplier moves the point FIRST, floor then widens it. + """Degenerate (identical-price) range: multiplier moves the point FIRST, floor + then widens it. - A single analog collapses Q1==Q3==median → zero-width asking range. With the - flag ON the бизнес point is multiplied to 10M×_BIZ, THEN the ±12 % floor widens - the (still zero-width) range symmetrically around that lifted point. If the floor - ran first, it would bracket the pre-multiply 10M point and the edges would not be - point±12 % of the multiplied point — this asserts they are. + Identical-price analogs collapse Q1==Q3==median → zero-width asking range. With + the flag ON the бизнес point is multiplied to 10M×_BIZ, THEN the ±12 % floor + widens the (still zero-width) range symmetrically around that lifted point. If + the floor ran first, it would bracket the pre-multiply 10M point and the edges + would not be point±12 % of the multiplied point — this asserts they are. + + #oblast-E: 5 identical-price lots (>= HEADLINE_LISTINGS_MIN_N) rather than a + literal n=1 — see the analogous comment in test_estimator_range_floor.py. """ monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True) monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS) - pr = _call(listings=_lots(200_000.0, n=1)) - assert pr.n_analogs == 1 + pr = _call(listings=_lots(200_000.0, n=5)) + assert pr.n_analogs == 5 point = pr.median_price assert point == round(10_000_000 * _BIZ) # multiplied point half = round(RANGE_MIN_HALFWIDTH_PCT * point) diff --git a/tradein-mvp/backend/tests/test_estimator_source_quota.py b/tradein-mvp/backend/tests/test_estimator_source_quota.py index 0b1e562a..9b02b85c 100644 --- a/tradein-mvp/backend/tests/test_estimator_source_quota.py +++ b/tradein-mvp/backend/tests/test_estimator_source_quota.py @@ -1,9 +1,10 @@ """Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix). Regression: Монтёрская 8/2 — 91 Avito listings с distance=0 выдавливали -Cian/Yandex/N1 из топ-50, т.к. pure-distance sort + LIMIT 50. +Cian/Yandex из топ-50, т.к. pure-distance sort + LIMIT 50. Fix: MAX_ANALOGS_PER_ADDRESS cap в SQL + MIN_ANALOGS_PER_SOURCE quota в Python. """ + import os # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. @@ -15,6 +16,7 @@ from unittest.mock import MagicMock # ── Helpers ─────────────────────────────────────────────────────────────────── + def _make_listing( *, source: str, @@ -58,6 +60,7 @@ def _make_db_mock(rows: list[dict[str, Any]]) -> MagicMock: # ── Test 1: per-address cap ─────────────────────────────────────────────────── + def test_address_cap_limits_per_address_listings() -> None: """_fetch_analogs caps at MAX_ANALOGS_PER_ADDRESS listings from a single address. @@ -91,6 +94,7 @@ def test_address_cap_limits_per_address_listings() -> None: # ── Test 2: source quota (regression for Cian starvation) ──────────────────── + def test_source_quota_prevents_cian_starvation() -> None: """MIN_ANALOGS_PER_SOURCE guarantees Cian is represented despite Avito dominance. @@ -103,22 +107,28 @@ def test_source_quota_prevents_cian_starvation() -> None: # SQL already applied address cap. Simulate SQL result after cap: # 5 avito (cap applied to large block), 8 cian (different address, 200m away). avito_rows = [ - _make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0, - relevance_score=float(i) * 0.01) + _make_listing( + source="avito", + address="ул. Монтёрская, 8/2", + distance_m=0.0, + relevance_score=float(i) * 0.01, + ) for i in range(5) ] cian_rows = [ - _make_listing(source="cian", address="ул. Монтёрская, 1", distance_m=200.0, - relevance_score=0.2 + float(i) * 0.01) + _make_listing( + source="cian", + address="ул. Монтёрская, 1", + distance_m=200.0, + relevance_score=0.2 + float(i) * 0.01, + ) for i in range(8) ] # SQL returns avito first (better relevance), then cian sql_rows = avito_rows + cian_rows db = _make_db_mock(sql_rows) - result, _, _ = _fetch_analogs( - db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 - ) + result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000) cian_count = sum(1 for r in result if r["source"] == "cian") expected_min = min(8, MIN_ANALOGS_PER_SOURCE) @@ -130,6 +140,7 @@ def test_source_quota_prevents_cian_starvation() -> None: # ── Test 3: no source starvation when quota > supply ───────────────────────── + def test_source_quota_includes_all_when_supply_below_min() -> None: """When a source has fewer listings than MIN_ANALOGS_PER_SOURCE, all are included. @@ -138,31 +149,38 @@ def test_source_quota_includes_all_when_supply_below_min() -> None: from app.services.estimator import _fetch_analogs avito_rows = [ - _make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0, - relevance_score=float(i) * 0.01) + _make_listing( + source="avito", + address="ул. Монтёрская, 8/2", + distance_m=0.0, + relevance_score=float(i) * 0.01, + ) for i in range(5) ] cian_rows = [ - _make_listing(source="cian", address="ул. Монтёрская, 3", distance_m=300.0, - relevance_score=0.3 + float(i) * 0.01) + _make_listing( + source="cian", + address="ул. Монтёрская, 3", + distance_m=300.0, + relevance_score=0.3 + float(i) * 0.01, + ) for i in range(3) ] sql_rows = avito_rows + cian_rows db = _make_db_mock(sql_rows) - result, _, _ = _fetch_analogs( - db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000 - ) + result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000) cian_count = sum(1 for r in result if r["source"] == "cian") - assert cian_count == 3, ( - f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}" - ) + assert ( + cian_count == 3 + ), f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}" assert len(result) == 8 # 5 avito + 3 cian # ── Test 4: fallback signal preserved ──────────────────────────────────────── + def test_fallback_signal_reflects_radius() -> None: """_fetch_analogs returns correct fallback_used boolean based on radius_m. @@ -172,8 +190,9 @@ def test_fallback_signal_reflects_radius() -> None: from app.services.estimator import DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, _fetch_analogs rows = [ - _make_listing(source="avito", address="ул. Ленина, 1", distance_m=100.0, - relevance_score=0.1), + _make_listing( + source="avito", address="ул. Ленина, 1", distance_m=100.0, relevance_score=0.1 + ), ] db_default = _make_db_mock(rows) diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index 0803ab8f..b5d6ebbe 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -24,6 +24,14 @@ from app.services.estimator import ( from app.services.scraper_settings import get_scraper_delay +def _history_rows(db) -> list[dict]: + """Строки батча house_placement_history из мока сессии (фильтр по SQL, не по позиции).""" + for call in db.execute.call_args_list: + if "INSERT INTO house_placement_history" in str(call.args[0]): + return call.args[1] + return [] + + def _sample_result(address: str = "Екатеринбург, ул. Учителей, 18") -> YandexValuationResult: return YandexValuationResult( address=address, @@ -170,15 +178,22 @@ def test_save_history_items_inserts_each(): ): saved = _save_yandex_history_items(db, result) assert saved == 2 - # 1 batch INSERT (executemany) + 1 commit - assert db.execute.call_count == 1 - args = db.execute.call_args - rows = args.args[1] + # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE + # houses.has_panorama — считаем именно вставки истории, а не все execute. + rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 - db.commit.assert_called_once() + # Два коммита: панорама (до истории) + батч истории. Раньше был один. + assert db.commit.call_count == 2 def test_save_history_items_empty_no_commit(): + """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. + + #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно + отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%). + Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается — + а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает. + """ db = MagicMock() result = YandexValuationResult( address="x", @@ -189,11 +204,13 @@ def test_save_history_items_empty_no_commit(): house=ValuationHouseMeta(), history_items=[], ) - # match_or_create_house must NOT be called when there are no items (early return) - with patch("app.services.estimator.match_or_create_house") as m: + with patch( + "app.services.estimator.match_or_create_house", + return_value=(1, 0.9, "fingerprint"), + ) as m: saved = _save_yandex_history_items(db, result) assert saved == 0 - m.assert_not_called() + m.assert_called_once() db.execute.assert_not_called() db.commit.assert_not_called() @@ -227,9 +244,20 @@ def test_save_history_items_ext_id_stable_across_calls(): def test_save_history_items_db_error_rolls_back_batch(): - """Any item failing rolls back the whole batch — batch semantics (finding #5).""" + """Any item failing rolls back the whole batch — batch semantics (finding #5). + + #2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение + доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает), + а батч истории проходил бы успешно, и тест молча проверял бы не тот путь. + """ db = MagicMock() - db.execute.side_effect = [RuntimeError("first row fails"), None] + + def _fail_history(sql, *args, **kwargs): + if "INSERT INTO house_placement_history" in str(sql): + raise RuntimeError("first row fails") + return MagicMock() + + db.execute.side_effect = _fail_history result = _sample_result() with patch( "app.services.estimator.match_or_create_house", @@ -238,4 +266,5 @@ def test_save_history_items_db_error_rolls_back_batch(): saved = _save_yandex_history_items(db, result) assert saved == 0 # whole batch rolled back db.rollback.assert_called_once() - db.commit.assert_not_called() + # Панорама коммитится отдельно и раньше — её успех не отменяет отката истории. + assert db.commit.call_count == 1 diff --git a/tradein-mvp/backend/tests/test_geocode_reverse_api.py b/tradein-mvp/backend/tests/test_geocode_reverse_api.py index a50bb838..7b5adebe 100644 --- a/tradein-mvp/backend/tests/test_geocode_reverse_api.py +++ b/tradein-mvp/backend/tests/test_geocode_reverse_api.py @@ -6,7 +6,7 @@ Bug context (issue #582 Phase 5): оставался где user кликнул — иногда в проезде / дворе. Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания -от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision +от Nominatim/cadastral) + precision, фронт двигает marker если precision in (exact, number, cadastral). """ @@ -46,6 +46,7 @@ def app() -> FastAPI: # ── Endpoint response shape ────────────────────────────────────────────────── + def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None: """Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision.""" client = TestClient(app) @@ -54,7 +55,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None: snapped_lat=56.838004, snapped_lon=60.586155, precision="exact", - provider="yandex", + provider="cadastral", ) with patch( "app.api.v1.geocode.reverse_geocode", @@ -73,7 +74,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None: assert body["snapped_lat"] == 56.838004 assert body["snapped_lon"] == 60.586155 assert body["precision"] == "exact" - assert body["provider"] == "yandex" + assert body["provider"] == "cadastral" def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None: @@ -113,6 +114,7 @@ def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAP # ── snap_precision_useful helper ───────────────────────────────────────────── + def test_snap_precision_useful_exact_and_number() -> None: assert snap_precision_useful("exact") is True assert snap_precision_useful("number") is True @@ -125,104 +127,3 @@ def test_snap_precision_useful_rejects_street_and_other() -> None: assert snap_precision_useful("locality") is False assert snap_precision_useful("other") is False assert snap_precision_useful("") is False - - -# ── Yandex reverse parsing ─────────────────────────────────────────────────── - -async def test_yandex_reverse_parses_snapped_point_and_precision() -> None: - """`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty.""" - import httpx - - from app.services.geocoder import _yandex_reverse - - sample = { - "response": { - "GeoObjectCollection": { - "featureMember": [ - { - "GeoObject": { - "metaDataProperty": { - "GeocoderMetaData": { - "precision": "exact", - "text": ( - "Россия, Свердловская область, " - "Екатеринбург, улица Малышева, 51" - ), - "kind": "house", - } - }, - "name": "улица Малышева, 51", - "Point": {"pos": "60.586155 56.838004"}, - } - } - ] - } - } - } - - class _FakeResp: - status_code = 200 - - def raise_for_status(self) -> None: - return None - - def json(self) -> dict: - return sample - - class _FakeClient: - async def __aenter__(self) -> _FakeClient: - return self - - async def __aexit__(self, *_: object) -> None: - return None - - async def get(self, *_: object, **__: object) -> _FakeResp: - return _FakeResp() - - with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()): - result = await _yandex_reverse(56.8381, 60.5860, api_key="fake") - - assert result is not None - # Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155 - assert abs(result.snapped_lat - 56.838004) < 1e-6 - assert abs(result.snapped_lon - 60.586155) < 1e-6 - assert result.precision == "exact" - assert result.provider == "yandex" - # Address text должен быть очищен от "Россия, Свердловская область" - assert "Россия" not in result.address - assert "Свердловская область" not in result.address - assert "Малышева" in result.address - assert "51" in result.address - - -async def test_yandex_reverse_returns_none_on_empty_results() -> None: - """Empty featureMember → None.""" - import httpx - - from app.services.geocoder import _yandex_reverse - - sample = {"response": {"GeoObjectCollection": {"featureMember": []}}} - - class _FakeResp: - status_code = 200 - - def raise_for_status(self) -> None: - return None - - def json(self) -> dict: - return sample - - class _FakeClient: - async def __aenter__(self) -> _FakeClient: - return self - - async def __aexit__(self, *_: object) -> None: - return None - - async def get(self, *_: object, **__: object) -> _FakeResp: - return _FakeResp() - - with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()): - result = await _yandex_reverse(56.0, 60.0, api_key="fake") - - assert result is None diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py index b0afa8ed..93457634 100644 --- a/tradein-mvp/backend/tests/test_geocoder_bbox.py +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -8,23 +8,23 @@ инвариант OBLAST66 ⊇ TIGHT, дальние города области, известное ограничение (Тюмень внутри bbox координатно — компенсируется region cross-check на accept-сайтах); - _has_oblast_marker — word-boundary matching (не substring); -- _nominatim_query / _yandex_lookup accept-сайты — two-pass tie-break (tight ЕКБ - приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox). +- _nominatim_query accept-сайт — two-pass tie-break (tight ЕКБ приоритетнее) + + region cross-check (отсекает Тюмень и т.п. даже внутри bbox). """ -from unittest.mock import patch - import httpx import pytest from app.services.geocoder import ( + _LOCALITY_MARKER_RE, EKB_BBOX_TIGHT, EKB_BBOX_WIDE, OBLAST66_BBOX, + _ekb_local_tiers_allowed, _has_oblast_marker, _names_non_ekb_city, + _names_unrecognized_locality, _nominatim_query, - _yandex_lookup, is_within_ekb_bbox, is_within_ekb_bbox_wide, is_within_oblast66_bbox, @@ -142,9 +142,9 @@ def test_oblast66_bbox_admits_tyumen_by_design() -> None: Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле false-positive у границы, чем false-negative на корректном адресе области). - Реальное отсечение Тюмени происходит на accept-сайтах через region - cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate / - test_yandex_lookup_rejects_out_of_region_only_candidate ниже. + Реальное отсечение Тюмени происходит на accept-сайте через region + cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate + ниже. """ assert is_within_oblast66_bbox(57.1530, 65.5343) is True @@ -202,6 +202,162 @@ def test_names_non_ekb_city(address: str, expected: bool) -> None: assert _names_non_ekb_city(address) is expected +# ── _names_unrecognized_locality / _ekb_local_tiers_allowed (#2582) ───────── +# Fail-closed гейт: EKB-only локальные тиры (geoportal/cad_buildings) больше +# НЕ держатся на списке из 37 городов ("нет в списке → считаем ЕКБ"). Живые +# прод-случаи из geocode_cache (все получили координаты ЕКБ по ошибке до +# фикса): Ялта (другой регион), Трёхгорный (другой регион), Лёвиха/Реж +# (реальные города Свердловской области ВНЕ списка 37). + + +@pytest.mark.parametrize( + "address,expected", + [ + # Живые прод-случаи (#2582) — региональный/settlement маркер есть, + # но это не Екатеринбург и не город из известного списка: + ("республика крым, ялта, ул. репина, 18с2", True), + ("челябинская обл., трёхгорный, ул. ленина, 15", True), + ("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", True), + ("свердловская обл., реж, ул. ленина, 5", True), + ("ульяновская обл., димитровград, ул. менделеева, 11", True), + # известный город списка ловится отдельно (_names_non_ekb_city), + # эта функция на него не обязана реагировать без маркера: + ("нижний тагил, ленина 10", False), + # города/региона вообще не названо — основной трафик, не топоним: + ("малышева 30", False), + ("серова 27", False), # word-boundary — не город "серов" + ("проспект ленина, 1", False), + # явный Екатеринбург побеждает даже при наличии маркера "г": + ("г. екатеринбург, ул. малышева, 30", False), + ("г екатеринбург, ленинский р-н, ул серова, д 35", False), + # ── Изолирующие кейсы (#2589 M3) — параметризация выше зелёная и на + # маркер, и на «обл./республика/край» одновременно (все реальные + # прод-адреса их несут вместе); ниже — маркер БЕЗ регионального + # префикса, чтобы поймать регрессию именно сигнала-маркера (это и + # проехало у H1: мёртвый «с\.» был бы незамечен без таких кейсов). + ( + "с. новоалексеевское, ул. 8 марта,37", + True, + ), # живой инцидент (#2589 H1) — «с.» БЕЗ доп. маркера + ("с. кашино, ул. ленина, 5", True), # изолированный «с.» (M3) + ("д. родина, ул. мира, 1", True), # изолированный «д.» (M3, lookahead-фикс) + ("пгт. шаля, ул. советская, 2", True), # изолированный «пгт.» (M3) + ( + "пос. мичуринский, широкореченская ул., 45", + True, + ), # пригородный посёлок ЕКБ — маркер закрывает гейт (M3, честно + # закреплено: даже для формально «своих» посёлков без «Екатеринбург» + # в тексте фолбэк на oblast-aware внешние провайдеры — не регрессия + # координат, просто не через fast-path geoportal) + ("реж, ленина 5", False), # ИЗВЕСТНАЯ ДЫРА (#2589 M3): голый город вне + # списка 37 БЕЗ маркера — не ловится, уходит в ЕКБ-дефолт. Честно + # зафиксировано, не «фиксим» здесь (см. docstring — architectural + # follow-up отдельно, не через список городов). + # house-маркер НЕ должен матчить как топоним (регрессия #2582→#2589): + ("ул. ленина, д. 5", False), + ("д. 5", False), + ("д. 5 стр. 2", False), + ], +) +def test_names_unrecognized_locality(address: str, expected: bool) -> None: + assert _names_unrecognized_locality(address) is expected + + +@pytest.mark.parametrize( + "text,expected", + [ + # Ровно набор из deep-review (#2589 H1) — «Проверь сам на живом Python». + ("д. 5", False), + ("д. 5 стр. 2", False), + ("ул. ленина, д. 5", False), + ("с. кашино", True), + ("д. родина", True), + ("пгт. шаля", True), + ], +) +def test_locality_marker_re_isolated(text: str, expected: bool) -> None: + """Regex-уровень, изолированно от `_names_unrecognized_locality`'s + Екатеринбург-early-return — прямая проверка `_LOCALITY_MARKER_RE` (#2589 M3/H1).""" + assert bool(_LOCALITY_MARKER_RE.search(text)) is expected + + +@pytest.mark.parametrize( + "address,expected", + [ + # Живые прод-случаи (#2582) — гейт обязан закрыться: + ("республика крым, ялта, ул. репина, 18с2", False), + ("челябинская обл., трёхгорный, ул. ленина, 15", False), + ("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", False), + ("свердловская обл., реж, ул. ленина, 5", False), + # известный город региона 66 (список) — тоже закрыт: + ("Нижний Тагил, проспект Ленина, 1", False), + ("Каменск-Уральский, ул. Ленина 1", False), + # ЕКБ явно назван — гейт открыт (regardless маркера "г"): + ("г. Екатеринбург, ул. Малышева, 30", True), + ("620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35", True), + # города/региона вообще не названо — ОСНОВНОЙ ТРАФИК (форма оценки без + # города) — дефолт ЕКБ должен остаться рабочим, иначе деградация #1: + ("Малышева 30", True), + ("Серова 27", True), + ("Космонавтов 7б", True), + ("проспект Ленина, 1", True), + # мкр-исключение (район внутри ЕКБ, не ЗАТО Заречный) — гейт открыт: + ("мкр Заречный, ул. Ленина 5", True), + # изолирующий кейс (#2589 H1/M3) — «с.»-маркер БЕЗ доп. «обл.»/«республика»: + ("с. Новоалексеевское, ул. 8 Марта,37", False), + # house-маркер «д.» НЕ закрывает гейт (регрессия H1-фикса): + ("ул. Ленина, д. 5", True), + ], +) +def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None: + assert _ekb_local_tiers_allowed(address) is expected + + +# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582, #2589 M2) ───── +# Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что +# ни одна из веток не стала недостижимой после слияния #2580/#2582/#2589-M2: +# A. city_hint называет известный город региона 66 (не ЕКБ) → закрыто. +# A2. city_hint — ЛЮБАЯ непустая строка, отличная от "Екатеринбург" (в т.ч. +# НЕ из списка 37 — #2589 M2, было главной дырой: `city_hint="Реж"` +# раньше держал гейт открытым, geoportal отдавал точный ЕКБ-дом с +# `city_ambiguous=False` — уверенно-неверный результат) → закрыто. +# B. незнакомый топоним в ТЕКСТЕ адреса (маркер) → закрыто. +# C. ни того ни другого → открыто (дефолт ЕКБ). +# D. явный city_hint="Екатеринбург" (без конфликта в тексте) → открыто. + + +@pytest.mark.parametrize( + "address,city_hint,expected,label", + [ + # A: hint называет известный не-ЕКБ город региона 66 → закрыто, даже + # если сам текст адреса города вообще не называет (#2580 C1). + ("Ленина, 1", "Нижний Тагил", False, "A: hint=известный не-ЕКБ город"), + ("Серова 27", "Каменск-Уральский", False, "A: hint=известный не-ЕКБ город (2)"), + # A2 (#2589 M2 — ключевой регрессионный кейс фикса): hint называет + # город, КОТОРОГО НЕТ в списке 37 (произвольный текст через API, не + # UI-selector) → тоже закрыто. До фикса `_names_non_ekb_city(city_hint)` + # давал False (Реж не в списке) → гейт был открыт. + ("Ленина, 1", "Реж", False, "A2: hint=не-ЕКБ город ВНЕ списка 37"), + ("Серова 27", "Ялта", False, "A2: hint=город другого региона"), + # B: незнакомый топоним в тексте (маркер) закрывает гейт независимо + # от hint (#2582) — hint здесь не задан, чистая проверка B. + ("свердловская обл., реж, ул. ленина, 5", None, False, "B: маркер в тексте"), + # C: ни маркера в тексте, ни (не-ЕКБ) hint'а → открыто, основной трафик. + ("Малышева 30", None, True, "C: ничего не названо"), + ("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"), + # D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто — + # ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный + # ЕКБ-путь через hint. + ("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"), + ("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"), + ], +) +def test_ekb_local_tiers_allowed_with_city_hint( + address: str, city_hint: str | None, expected: bool, label: str +) -> None: + assert _ekb_local_tiers_allowed(address, city_hint) is expected, label + + # ── Accept-site two-pass tie-break + region cross-check ───────────────────── @@ -260,66 +416,3 @@ async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None: _REAL_ASYNC_CLIENT = httpx.AsyncClient - - -def _yandex_client_factory(transport: httpx.MockTransport): - """Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup.""" - - def factory(*_: object, **__: object) -> httpx.AsyncClient: - return _REAL_ASYNC_CLIENT(transport=transport) - - return factory - - -def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict: - meta: dict = {"precision": "exact", "text": "тест"} - if admin_area is not None: - meta["AddressDetails"] = { - "Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}} - } - return { - "GeoObject": { - "Point": {"pos": f"{lon} {lat}"}, - "metaDataProperty": {"GeocoderMetaData": meta}, - } - } - - -def _yandex_payload(members: list[dict]) -> dict: - return {"response": {"GeoObjectCollection": {"featureMember": members}}} - - -async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None: - """Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5.""" - members = [ - _yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0 - _yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1 - ] - payload = _yandex_payload(members) - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=payload) - - transport = httpx.MockTransport(handler) - with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): - result = await _yandex_lookup("Серова 27", "fake-key") - - assert result is not None - assert result.lat == pytest.approx(56.838) - assert result.lon == pytest.approx(60.605) - - -async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None: - """Единственный кандидат — Тюмень (bbox admits координатно, регион другой) → - region cross-check отсекает даже в ultimate as-is fallback → None.""" - members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")] - payload = _yandex_payload(members) - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=payload) - - transport = httpx.MockTransport(handler) - with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): - result = await _yandex_lookup("какой-то мусорный адрес", "fake-key") - - assert result is None diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index c5d407fc..919f9386 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -12,6 +12,7 @@ Covers: from __future__ import annotations import os +import re import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -26,9 +27,13 @@ sys.modules.setdefault("weasyprint", _wp_mock) from app.services.geocoder import ( # noqa: E402 + _SQL_HOUSE_TOKEN_RE, + SVERDLOVSK_OBLAST_REGION, GeocodeResult, GeocodeSuggestion, _cadastral_house_match, + _dadata_suggest, + _norm_house, _parse_street_house, geocode, suggest, @@ -143,8 +148,8 @@ def test_house_match_returns_none_for_non_numeric_house() -> None: db.execute.assert_not_called() -def test_house_match_passes_only_digits_as_regex_param() -> None: - """house='26а' → :house_digits bound param is '26' (letter stripped for regex).""" +def _house_match_params(house: str, street: str = "космонавтов") -> dict: + """Вызывает матчер с mock-сессией и возвращает bound-params запроса.""" row = MagicMock() row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а" row.lat = 56.9 @@ -154,13 +159,58 @@ def test_house_match_passes_only_digits_as_regex_param() -> None: result.first.return_value = row db.execute.return_value = result - _cadastral_house_match(db, "космонавтов", "26а") + _cadastral_house_match(db, street, house) # second positional arg to execute() is the bound-params dict - params = db.execute.call_args.args[1] - assert params["house_digits"] == "26" - assert params["house_full"] == "26а" + return db.execute.call_args.args[1] + + +def test_house_match_passes_full_normalized_house_not_just_digits() -> None: + """house='26а' → в запрос уходит ПОЛНЫЙ номер '26а', а не только цифры '26'. + + Регрессия-гард на исходный баг: раньше литера отрезалась (`house_digits` + = '26') и в WHERE была опциональна, поэтому «26а» матчился на дом «26». + Цифры остаются отдельным параметром — но только как дешёвый prefilter. + """ + params = _house_match_params("26а") + + assert params["house_norm"] == "26а" + assert params["house_digits"] == "26" # prefilter only assert params["street"] == "космонавтов" + # Литера больше не «подсказка для сортировки» — старый параметр ушёл. + assert "house_full" not in params + + +@pytest.mark.parametrize( + ("raw_house", "expected_norm"), + [ + ("13б", "13б"), + ("13 б", "13б"), + ("13-б", "13б"), + ("13Б", "13б"), + ("13 Б", "13б"), + ("13", "13"), + ], +) +def test_house_match_normalizes_letter_spellings(raw_house: str, expected_norm: str) -> None: + """«13б» / «13 б» / «13-б» / «13Б» — одна и та же литера, один канон.""" + assert _house_match_params(raw_house, street="новгородцевой")["house_norm"] == expected_norm + + +def test_house_match_sql_compares_house_by_equality() -> None: + """SQL сравнивает нормализованный номер РАВЕНСТВОМ, а не «литера опциональна». + + Структурный гард: если кто-то вернёт матч по цифрам с опциональной литерой + (`[а-яё]?` в WHERE как единственная проверка дома), тест упадёт. + """ + db = MagicMock() + db.execute.return_value.first.return_value = None + _cadastral_house_match(db, "новгородцевой", "13б") + + sql = str(db.execute.call_args.args[0]) + assert "= CAST(:house_norm AS text)" in sql + # tie-break по литере в ORDER BY больше не решает корректность + assert "house_full" not in sql # ── geocode() wiring ───────────────────────────────────────────────────────── @@ -188,7 +238,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None: "app.services.geocoder._cadastral_forward_sync", ) as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, ): result = await geocode("Серова 27", db) @@ -199,7 +248,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None: mock_house.assert_called_once() # house-match hit → legacy raw-ILIKE forward never invoked mock_forward.assert_not_called() - mock_yandex.assert_not_called() async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None: @@ -225,7 +273,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> return_value=[legacy_hit], ) as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, ): result = await geocode("Серова 27", db) @@ -233,7 +280,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> assert result.lat == 56.84 mock_house.assert_called_once() mock_forward.assert_called_once() - mock_yandex.assert_not_called() async def test_geocode_skips_house_match_when_parse_fails() -> None: @@ -250,14 +296,12 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None: return_value=[], ) as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=None, ), ): - mock_settings.yandex_geocoder_api_key = None result = await geocode("полный мусор без дома", db) assert result is None @@ -290,14 +334,12 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cache_put"), - patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=nominatim_result, ) as mock_nominatim, ): - mock_settings.yandex_geocoder_api_key = None result = await geocode("Нижний Тагил, проспект Ленина, 1", db) assert result is not None @@ -309,6 +351,172 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: mock_nominatim.assert_called_once() +# ── geocode() fail-closed gate for UNRECOGNIZED locality (#2582) ──────────── +# Живые прод-случаи из geocode_cache: адрес называет город/регион ВНЕ списка +# из 37 (или другой регион РФ целиком) — до фикса `_names_non_ekb_city` +# молчаливо считал это «город не назван» → EKB-only тиры отвечали первыми и +# снапали улицу+дом в координаты ЕКБ. + + +@pytest.mark.parametrize( + ("address", "label"), + [ + ("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"), + ("челябинская обл., трёхгорный, ул. ленина, 15", "Трёхгорный (другой регион)"), + ( + "свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", + "Лёвиха (город области ВНЕ списка 37)", + ), + ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), + ( + "с. Новоалексеевское, ул. 8 Марта,37", + "Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)", + ), + ], +) +async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( + address: str, label: str +) -> None: + """EKB-only локальные тиры (geoportal/cadastral) НЕ вызываются для адреса, + называющего незнакомый топоним — идём сразу к внешнему oblast/nation-aware + провайдеру (#2582).""" + db = MagicMock() + nominatim_result = GeocodeResult( + lat=45.0, + lon=34.0, + full_address=address, + provider="nominatim", + confidence="approximate", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match") as mock_geoportal, + patch("app.services.geocoder._cadastral_house_match") as mock_house, + patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=nominatim_result, + ) as mock_nominatim, + ): + result = await geocode(address, db) + + assert result is not None, label + mock_geoportal.assert_not_called() + mock_house.assert_not_called() + mock_forward.assert_not_called() + mock_nominatim.assert_called_once() + + +@pytest.mark.parametrize( + ("address", "label"), + [ + ("Серова 27", "ЕКБ без города (основной трафик формы оценки)"), + ("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"), + ("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"), + ("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"), + ], +) +async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address( + address: str, label: str +) -> None: + """Регрессия: реальный ЕКБ-адрес (с городом и без) по-прежнему резолвится + через geoportal (fast-path) — fail-closed гейт не должен деградировать + основной трафик (#2582).""" + db = MagicMock() + hit = GeocodeSuggestion( + label="ул. Серова, д. 27, Екатеринбург", + full_address="ул. Серова, д. 27, Екатеринбург", + lat=56.81188, + lon=60.59739, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal, + patch("app.services.geocoder._cache_put"), + ): + result = await geocode(address, db) + + assert result is not None, label + assert result.lat == pytest.approx(56.81188), label + mock_geoportal.assert_called_once() + + +async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() -> None: + """Слияние #2580/#2582: явный `city_hint="Екатеринбург"` (без города в самом + тексте адреса, без конфликта) по-прежнему открывает ЕКБ-only локальные + тиры — ветка D из объединённого гейта `_ekb_local_tiers_allowed` не должна + была стать недостижимой после merge.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="ул. Серова, д. 27, Екатеринбург", + full_address="ул. Серова, д. 27, Екатеринбург", + lat=56.81188, + lon=60.59739, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal, + patch("app.services.geocoder._cache_put"), + ): + result = await geocode("Серова 27", db, city_hint="Екатеринбург") + + assert result is not None + assert result.lat == pytest.approx(56.81188) + mock_geoportal.assert_called_once() + + +async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None: + """#2589 M2 (deep-review fix): `city_hint="Реж"` — город ВНЕ списка 37 и + без структурного маркера в самом тексте адреса — обязан закрыть ЕКБ-only + локальные тиры так же, как известный `city_hint`. До фикса + `hint_names_non_ekb = _names_non_ekb_city(city_hint)` был list-based и + держал гейт открытым для ЛЮБОГО города вне списка 37 (Реж, седьмой город + после расширения UI-selector'а, произвольный текст через API) — geoportal + отдавал точный ЕКБ-хит, а `city_ambiguous=False` (хинт ведь БЫЛ) делало + это уверенно-неверным результатом (хуже, чем честный `city_ambiguous=True` + без хинта вообще).""" + db = MagicMock() + ekb_hit = GeocodeSuggestion( + label="Ленина, 1, Екатеринбург", + full_address="Ленина, 1, Екатеринбург", + lat=56.83788, + lon=60.58018, + kind="house", + ) + rezh_result = GeocodeResult( + lat=57.371, lon=61.386, full_address="Ленина, 1, Реж", provider="nominatim" + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._geoportal_house_match", return_value=ekb_hit + ) as mock_geoportal, + patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house, + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=rezh_result, + ), + ): + result = await geocode("Ленина, 1", db, city_hint="Реж") + + mock_geoportal.assert_not_called() + mock_house.assert_not_called() + assert result is not None + assert result.lat == pytest.approx(57.371) # Реж, НЕ подставленный ЕКБ-хит (56.83788) + assert result.lat != pytest.approx(56.83788) + + # ── suggest() wiring ───────────────────────────────────────────────────────── @@ -331,7 +539,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None: patch( "app.services.geocoder._cadastral_forward_sync", ) as mock_forward, - patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex, ): results = await suggest("Серова 27", db=db, limit=8) @@ -339,7 +546,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None: assert results[0].lat == 56.81188 mock_house.assert_called_once() mock_forward.assert_not_called() - mock_yandex.assert_not_called() async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None: @@ -364,7 +570,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> "app.services.geocoder._cadastral_forward_sync", return_value=legacy, ) as mock_forward, - patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex, ): results = await suggest("Серова 27", db=db, limit=8) @@ -372,7 +577,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> assert results[0].lat == 56.84 mock_house.assert_called_once() mock_forward.assert_called_once() - mock_yandex.assert_not_called() async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: @@ -401,7 +605,6 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: ) as mock_nominatim, ): mock_settings.dadata_api_token = None - mock_settings.yandex_geocoder_api_key = None results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8) assert len(results) == 1 @@ -409,3 +612,244 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: mock_house.assert_not_called() mock_forward.assert_not_called() mock_nominatim.assert_called_once() + + +@pytest.mark.parametrize( + ("query", "label"), + [ + ("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"), + ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), + ], +) +async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality( + query: str, label: str +) -> None: + """Незнакомый топоним (не в списке 37, не Екатеринбург) → EKB-only + cadastral Tier 1 не вызывается; falls through к внешним тирам (#2582).""" + db = MagicMock() + nominatim_hit = [ + GeocodeSuggestion( + label="ул. Репина, 18с2", + full_address=query, + lat=44.5, + lon=34.16, + kind="house", + ) + ] + + with ( + patch("app.services.geocoder._cadastral_house_match") as mock_house, + patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, + patch("app.services.geocoder.settings") as mock_settings, + patch( + "app.services.geocoder._nominatim_suggest", + new_callable=AsyncMock, + return_value=nominatim_hit, + ) as mock_nominatim, + ): + mock_settings.dadata_api_token = None + results = await suggest(query, db=db, limit=8) + + assert len(results) == 1, label + mock_house.assert_not_called() + mock_forward.assert_not_called() + mock_nominatim.assert_called_once() + + +# ── House-letter matching semantics ───────────────────────────────────────── +# Само сравнение дома выполняет Postgres, поэтому здесь — зеркало SQL-выражения +# на Python. Паттерн НЕ дублируется: он выводится из той же константы +# `_SQL_HOUSE_TOKEN_RE`, что уходит в запрос (Postgres `\m` = «начало слова» +# ≡ Python `\b` перед словесным символом). Правка SQL-регекспа автоматически +# меняет и эти проверки — рассинхрон невозможен. +# Строки-адреса — реальные формы `readable_address` из gendesign_cad_buildings. + + +def _sql_house_token(readable_address: str) -> str: + """Зеркало `_SQL_HOUSE_TOKEN_NORM`: извлечь номер дома и привести к канону.""" + py_pattern = _SQL_HOUSE_TOKEN_RE.replace("\\m", "\\b") + m = re.search(py_pattern, readable_address, re.IGNORECASE) + token = (m.group(1) if m else "").lower() + token = re.sub(r"\s", "", token) + return re.sub(r"-([а-яё])", r"\1", token) + + +@pytest.mark.parametrize( + ("readable_address", "expected"), + [ + # Литера в трёх написаниях + регистр → один канон + ("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", "7б"), + ("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", "23б"), + ("Свердловская область, г. Екатеринбург, ул. X, д. 18 б", "18б"), + ("Свердловская область, г. Екатеринбург, ул. X, д. 13Б", "13б"), + # Без литеры + ("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 13", "13"), + ("Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", "13"), + ("Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", "30"), + ("Российская Федерация, город Екатеринбург, улица Малышева, строение 30 в", "30в"), + # Хвосты, которые литерой НЕ являются + ("Свердловская область, г. Екатеринбург, ул. X, д. 11 (кв. 1-150)", "11"), + ("Свердловская область, г. Екатеринбург, ул. X, д. 25, корп. 1", "25"), + ("Российская Федерация, город Екатеринбург, улица X, дом 102 корпус 1", "102"), + ("Свердловская область, г. Екатеринбург, ул. X, д. 16 угол улица Титова", "16"), + # Угловые/корпусные номера — ЧАСТЬ номера, не отбрасываются + ("Свердловская область, г. Екатеринбург, ул. X, д. 58/3", "58/3"), + ("Свердловская область, г. Екатеринбург, ул. X, д. 36/24а", "36/24а"), + # Дефис перед ЦИФРОЙ не схлопывается (иначе «64-2» стало бы домом «642») + ("Свердловская область, г. Екатеринбург, ул. X, д. 64-2", "64-2"), + ("Свердловская область, г. Екатеринбург, ул. X, д. 642", "642"), + # Маркер только с начала слова: «проезд» не даёт дом «8» + ("Свердловская область, г Екатеринбург, проезд 8 Марта, д 5", "5"), + ("Свердловская область, г Екатеринбург, ул Привокзальная, д 22", "22"), + # Нет дом-маркера → номер не извлекаем (адрес недостижим этим тиром) + ("Свердловская область, город Екатеринбург, проезд 4-й ЕКАД Южный", ""), + ], +) +def test_sql_house_token_extraction(readable_address: str, expected: str) -> None: + assert _sql_house_token(readable_address) == expected + + +@pytest.mark.parametrize( + ("query_house", "readable_address", "should_match", "label"), + [ + # ── Прод-баг #1: «Новгородцевой 13б» отдавал дом 13 как exact ────── + ( + "13б", + "Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", + False, + "запрос С литерой не берёт дом БЕЗ литеры", + ), + # ── Прод-баг #2 (обратный): «Малышева 30» отдавал «д. 30-б» ──────── + ( + "30", + "Свердловская область, г. Екатеринбург, ул. Малышева, д. 30-б", + False, + "запрос БЕЗ литеры не берёт дом С литерой", + ), + ( + "7б", + "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7в", + False, + "другая литера не матчится", + ), + # ── Позитив: литера совпала во всех написаниях реестра ───────────── + ("7б", "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", True, "«13б»"), + ( + "23б", + "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", + True, + "«13-б»", + ), + ("18б", "Свердловская область, г. Екатеринбург, ул. X, д. 18 б", True, "«13 б»"), + ("13б", "Свердловская область, г. Екатеринбург, ул. X, д. 13Б", True, "регистр"), + # ── Позитив: без литеры ──────────────────────────────────────────── + ( + "13", + "Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", + True, + "«дом N»", + ), + ( + "30", + "Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", + True, + "«сооружение N»", + ), + # Префикс числа не считается совпадением + ("13", "Свердловская область, г. Екатеринбург, ул. X, д. 130", False, "13 ≠ 130"), + # Угловой номер не подменяет простой + ("58", "Свердловская область, г. Екатеринбург, ул. X, д. 58/3", False, "58 ≠ 58/3"), + ], +) +def test_house_letter_match_semantics( + query_house: str, readable_address: str, should_match: bool, label: str +) -> None: + """Равенство нормализованных номеров — обе стороны приводятся к одному канону.""" + matched = _sql_house_token(readable_address) == _norm_house(query_house) + assert matched is should_match, label + + +def test_query_letter_house_falls_through_instead_of_returning_neighbour() -> None: + """Нет дома с литерой → None (не «похожий» дом) → работают следующие тиры. + + Ключевое свойство фикса: молчаливая подмена соседнего здания здесь + помечалась бы `confidence="exact"` и кэшировалась на 90 дней. + """ + db = MagicMock() + db.execute.return_value.first.return_value = None # дома «13б» в реестре нет + + assert _cadastral_house_match(db, "новгородцевой", "13б") is None + assert db.execute.call_args.args[1]["house_norm"] == "13б" + + +async def test_geocode_letter_house_miss_reaches_nominatim() -> None: + """«Новгородцевой 13б» без хита в реестре доходит до Nominatim, а не + возвращает дом 13 с `confidence="exact"`.""" + db = MagicMock() + nominatim_result = GeocodeResult( + lat=56.82, + lon=60.68, + full_address="ул. Новгородцевой, 13б, Екатеринбург", + provider="nominatim", + confidence="approximate", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None) as mock_house, + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=nominatim_result, + ) as mock_nominatim, + ): + result = await geocode("Екатеринбург, Новгородцевой 13б", db) + + assert result is not None + assert result.confidence == "approximate" + assert result.lat == pytest.approx(56.82) + mock_house.assert_called_once() + # в матчер ушёл ПОЛНЫЙ номер с литерой + assert mock_house.call_args.args[2] == "13б" + mock_nominatim.assert_called_once() + + +def test_parse_street_house_keeps_letter_in_all_spellings() -> None: + """Парсер отдаёт литеру матчеру в каноне — иначе строгий матч бесполезен.""" + assert _parse_street_house("Новгородцевой 13б") == ("новгородцевой", "13б") + assert _parse_street_house("Новгородцевой 13-б") == ("новгородцевой", "13б") + assert _parse_street_house("Новгородцевой 13 Б") == ("новгородцевой", "13б") + assert _parse_street_house("ул. Новгородцевой, д. 13б") == ("новгородцевой", "13б") + assert _parse_street_house("Новгородцевой 13") == ("новгородцевой", "13") + + +# ── DaData region constraint ──────────────────────────────────────────────── + + +def test_dadata_region_constant_has_no_region_type() -> None: + """DaData `locations.region` сравнивается с именем БЕЗ типа. + + «Свердловская область» — hard-filter, который не совпадает ни с чем и молча + даёт 0 подсказок (прод-баг). Тип живёт в `region_type`/`region_with_type`. + """ + assert SVERDLOVSK_OBLAST_REGION == "Свердловская" + lowered = SVERDLOVSK_OBLAST_REGION.lower() + for type_word in ("область", "обл", "край", "респ"): + assert type_word not in lowered, f"тип региона {type_word!r} ломает locations-фильтр" + + +async def test_dadata_suggest_passes_region_without_type() -> None: + """`_dadata_suggest` отдаёт в DaData именно region-константу (не город).""" + with patch( + "app.services.geocoder.dadata.suggest_addresses", + new_callable=AsyncMock, + return_value=[], + ) as mock_suggest: + assert await _dadata_suggest("Новгородцевой 13б", limit=5) == [] + + kwargs = mock_suggest.call_args.kwargs + assert kwargs["region"] == "Свердловская" + assert kwargs["city"] is None diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py new file mode 100644 index 00000000..7daa2023 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -0,0 +1,461 @@ +"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча. + +Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`, +`_nominatim_suggest` при отсутствии маркера города/области в самом адресе +всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, вводя +«Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина +(обе улицы называются одинаково). + +#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder — +их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix +без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`. + +Покрывают: +- `_resolve_city_for_geocode` — приоритет: маркер в адресе > `city_hint` > None. +- `_nominatim_suggest` — без города НЕ форсит city-суффикс молчаливо (dual-query); + с `city_hint` — суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение + НЕ изменилось (как раньше). +- `geocode()` — `city_ambiguous=True` когда город не указан ни в адресе, ни в + `city_hint`; `False` когда указан явно (текстом или через `city_hint`). +- Cache-ключ (`_cache_key`) — разные `city_hint` для одного текста адреса НЕ + делят одну запись кэша (regression test на cache poisoning). +""" + +from __future__ import annotations + +import contextlib +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +import pytest + +from app.services.geocoder import ( + GeocodeResult, + GeocodeSuggestion, + _cache_key, + _nominatim_suggest, + _resolve_city_for_geocode, + geocode, + known_city_hint, + suggest, +) + +# ── known_city_hint (#2603) ────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "value,expected", + [ + # Известные города области — значение проходит КАК ЕСТЬ (регистр/пробелы + # нормализует сам геокодер, caller'ы не должны его причёсывать). + ("Екатеринбург", "Екатеринбург"), + ("Нижний Тагил", "Нижний Тагил"), + ("нижний тагил", "нижний тагил"), + ("Каменск-Уральский", "Каменск-Уральский"), + # Мусор из хвоста росреестрового deals.city — это улицы/урочища, а не + # города; хинт из них закрыл бы EKB-тиры и уехал префиксом в запрос. + ("Бессонова", None), + ("Бердюгина", None), + ("Билейский рыбопитомник", None), + # Пусто — хинта нет. + (None, None), + ("", None), + (" ", None), + ], +) +def test_known_city_hint(value: str | None, expected: str | None) -> None: + assert known_city_hint(value) == expected + + +# ── _resolve_city_for_geocode ──────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "address,city_hint,expected", + [ + # Ни маркер, ни hint — раньше здесь молча подставлялся "Екатеринбург". + ("Ленина, 1", None, (None, False)), + ("Ленина, 1", "", (None, False)), + ("Ленина, 1", " ", (None, False)), + # city_hint передан явно вызывающим кодом. + ("Ленина, 1", "Нижний Тагил", ("Нижний Тагил", True)), + # Маркер уже в адресе — hint игнорируется (marker имеет приоритет). + ("Нижний Тагил, Ленина, 1", "Серов", (None, True)), + ("Екатеринбург, Малышева 30", None, (None, True)), + ("Екатеринбург, Малышева 30", "Серов", (None, True)), + ], +) +def test_resolve_city_for_geocode( + address: str, city_hint: str | None, expected: tuple[str | None, bool] +) -> None: + assert _resolve_city_for_geocode(address, city_hint) == expected + + +# ── _cache_key — cache poisoning между городами ────────────────────────────── + + +def test_cache_key_without_hint_unchanged() -> None: + """Без city_hint формат ключа НЕ меняется — backward-compat с накопленным кэшем.""" + assert _cache_key("ленина, 1", None) == "ленина, 1" + assert _cache_key("ленина, 1", "") == "ленина, 1" + + +def test_cache_key_different_cities_do_not_collide() -> None: + """#2576: разные города для одного текста адреса — разные cache-ключи.""" + key_tagil = _cache_key("ленина, 1", "Нижний Тагил") + key_ekb = _cache_key("ленина, 1", "Екатеринбург") + key_none = _cache_key("ленина, 1", None) + + assert key_tagil != key_ekb + assert key_tagil != key_none + assert key_ekb != key_none + + +def test_cache_key_hint_normalized() -> None: + """city_hint нормализуется (case/whitespace) — не создаёт лишних ключей.""" + assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ") + + +# ── _nominatim_suggest ─────────────────────────────────────────────────────── + + +async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None: + """#2580 (C2): без города — Nominatim получает ОБА запроса: bare (честный, + без города) И ЕКБ-suffixed (majority-трафик). Не подмена одним вариантом — + объединение (см. `test_nominatim_suggest_pobedy20_ekb_result_not_lost` ниже + — чистое удаление суффикса теряло реальные ЕКБ-адреса). + """ + calls: list[str] = [] + + async def fake_query_multi(client, query, limit): + calls.append(query) + return [] + + with ( + patch( + "app.services.geocoder._nominatim_query_multi", + new=AsyncMock(side_effect=fake_query_multi), + ), + patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()), + ): + result = await _nominatim_suggest("Ленина, 1") + + assert result == [] + assert "Ленина, 1" in calls # bare — честный, без города + assert "Ленина, 1, Екатеринбург" in calls # ЕКБ-вариант — не потерян + + +async def test_nominatim_suggest_city_hint_suffix() -> None: + calls: list[str] = [] + + async def fake_query_multi(client, query, limit): + calls.append(query) + return [] + + with patch( + "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi) + ): + await _nominatim_suggest("Ленина, 1", city_hint="Нижний Тагил") + + assert calls[0] == "Ленина, 1, Нижний Тагил" + + +async def test_nominatim_suggest_explicit_ekb_unchanged() -> None: + calls: list[str] = [] + + async def fake_query_multi(client, query, limit): + calls.append(query) + return [] + + with patch( + "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi) + ): + await _nominatim_suggest("Екатеринбург, Малышева 30") + + assert calls[0] == "Екатеринбург, Малышева 30" + + +async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None: + """#2580 (C2) regression — "Победы 20" (реальный кейс с прода, подтверждён + ревьюером): без города ЕКБ-кандидат ('Парк Победы' ЖК, Екатеринбург) должен + остаться в подсказках, НЕ потеряться в пользу похожего street-level матча + в Верхней Пышме. + + Симулирует реальные координаты: + 'Победы 20, Екатеринбург' → 56.899, 60.579 (ЖК "Парк Победы", Екатеринбург) + 'Победы 20' → 56.964, 60.610 (ул. Победы, Верхняя Пышма) + """ + ekb_item = { + "place_id": 1001, + "lat": "56.899", + "lon": "60.579", + "display_name": 'ЖК "Парк Победы", Орджоникидзевский район, Екатеринбург', + "address": {"road": "Победы", "house_number": "20", "suburb": "Орджоникидзевский район"}, + } + pyshma_item = { + "place_id": 1002, + "lat": "56.964", + "lon": "60.610", + "display_name": "улица Победы, 20, Верхняя Пышма", + "address": {"road": "улица Победы", "house_number": "20"}, + } + + async def fake_query_multi(client, query, limit): + if query.endswith(", Екатеринбург"): + return [ekb_item] + return [pyshma_item] + + with ( + patch( + "app.services.geocoder._nominatim_query_multi", + new=AsyncMock(side_effect=fake_query_multi), + ), + patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()), + ): + result = await _nominatim_suggest("Победы 20") + + assert result, "ожидались подсказки" + ekb_hits = [r for r in result if r.lat == pytest.approx(56.899)] + assert ekb_hits, "ЕКБ-кандидат ('Парк Победы') должен остаться в подсказках, не потеряться" + # ЕКБ-кандидат идёт первым (majority-трафик — привычный порядок для основных пользователей). + assert result[0].lat == pytest.approx(56.899) + # Верхняя Пышма тоже осталась в списке — honest alternative, не подменена. + pyshma_hits = [r for r in result if r.lat == pytest.approx(56.964)] + assert pyshma_hits, "не-ЕКБ кандидат тоже должен остаться (объединение, не замена)" + + +async def test_nominatim_suggest_dedupe_across_dual_query() -> None: + """Если bare и ЕКБ-suffixed запросы возвращают ОДИН и тот же item (по place_id) + — он не дублируется в итоговом списке подсказок.""" + same_item = { + "place_id": 42, + "lat": "56.838", + "lon": "60.605", + "display_name": "ул. Малышева, 30, Екатеринбург", + "address": {"road": "ул. Малышева", "house_number": "30"}, + } + + async def fake_query_multi(client, query, limit): + return [same_item] + + with ( + patch( + "app.services.geocoder._nominatim_query_multi", + new=AsyncMock(side_effect=fake_query_multi), + ), + patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()), + ): + result = await _nominatim_suggest("Малышева 30") + + assert len(result) == 1, "одинаковый place_id из обоих запросов не должен дублироваться" + + +# ── geocode() — city_ambiguous flag ────────────────────────────────────────── + + +def _geocode_patches(nominatim_result: GeocodeResult | None): + return ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._cache_put"), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch( + "app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result) + ), + ) + + +# ── C1 (#2580) — city_hint должен доходить до локальных ЕКБ-only тиров ────── + + +async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None: + """#2580 (C1): city_hint="Нижний Тагил" должен ЗАПРЕТИТЬ geoportal/cadastral + (ЕКБ-only базы) — иначе они возвращают "точный" ЕКБ-хит для улицы, которая + совпадает по названию, а `city_ambiguous=False` (хинт был!) делает такой + неверный результат ложно-уверенным. Мок geoportal нарочно возвращает ЕКБ-хит + (как в проде) — фикс должен НЕ дать ему сработать вообще. + """ + db = MagicMock() + ekb_hit = GeocodeSuggestion( + label="Ленина, 1, Екатеринбург", + full_address="Ленина, 1, Екатеринбург", + lat=56.83788, + lon=60.58018, + kind="house", + ) + tagil_result = GeocodeResult( + lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim" + ) + with contextlib.ExitStack() as stack: + stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None)) + stack.enter_context(patch("app.services.geocoder._cache_put")) + geoportal_mock = stack.enter_context( + patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit) + ) + cadastral_mock = stack.enter_context( + patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) + ) + stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[])) + stack.enter_context( + patch( + "app.services.geocoder._nominatim_lookup", + new=AsyncMock(return_value=tagil_result), + ) + ) + result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") + + geoportal_mock.assert_not_called() + cadastral_mock.assert_not_called() + assert result is not None + assert result.lat == pytest.approx(57.905) # Тагил, НЕ подставленный ЕКБ-хит (56.838) + assert result.lat != pytest.approx(56.83788) + + +async def test_geocode_real_ekb_address_still_uses_local_tiers() -> None: + """Сквозной кейс: реальный ЕКБ-адрес БЕЗ city_hint по-прежнему резолвится через + локальный geoportal-тир (ЕКБ-путь не деградировал после C1-фикса).""" + db = MagicMock() + ekb_hit = GeocodeSuggestion( + label="Малышева, 30, Екатеринбург", + full_address="Малышева, 30, Екатеринбург", + lat=56.8389, + lon=60.6057, + kind="house", + ) + with contextlib.ExitStack() as stack: + stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None)) + stack.enter_context(patch("app.services.geocoder._cache_put")) + geoportal_mock = stack.enter_context( + patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit) + ) + result = await geocode("Малышева, 30", db) + + geoportal_mock.assert_called_once() + assert result is not None + assert result.lat == pytest.approx(56.8389) + assert result.confidence == "exact" + assert result.city_ambiguous is True # город не указан — честный флаг + + +async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None: + """#2580 (C1): suggest(city_hint="Нижний Тагил") — Tier 1 (кадастр ЕКБ) НЕ должен + вызываться (раньше был мёртвым параметром для этого тира).""" + db = MagicMock() + with contextlib.ExitStack() as stack: + house_mock = stack.enter_context(patch("app.services.geocoder._cadastral_house_match")) + forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync")) + mock_settings = stack.enter_context(patch("app.services.geocoder.settings")) + mock_settings.dadata_api_token = None + stack.enter_context( + patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])) + ) + await suggest("Ленина, 1", db=db, city_hint="Нижний Тагил") + + house_mock.assert_not_called() + forward_mock.assert_not_called() + + +async def test_geocode_city_ambiguous_true_when_no_city_known() -> None: + """Ни адрес, ни city_hint не называют город → city_ambiguous=True.""" + db = MagicMock() + nominatim_result = GeocodeResult( + lat=56.838, lon=60.605, full_address="что-то", provider="nominatim" + ) + with contextlib.ExitStack() as stack: + for cm in _geocode_patches(nominatim_result): + stack.enter_context(cm) + result = await geocode("Малышева, 30", db) + + assert result is not None + assert result.city_ambiguous is True + + +async def test_geocode_city_ambiguous_false_when_marker_present() -> None: + """Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False.""" + db = MagicMock() + nominatim_result = GeocodeResult( + lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim" + ) + with contextlib.ExitStack() as stack: + for cm in _geocode_patches(nominatim_result): + stack.enter_context(cm) + result = await geocode("Екатеринбург, Малышева, 30", db) + + assert result is not None + assert result.city_ambiguous is False + + +async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None: + """city_hint передан вызывающим кодом → город указан → city_ambiguous=False.""" + db = MagicMock() + nominatim_result = GeocodeResult( + lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim" + ) + with contextlib.ExitStack() as stack: + for cm in _geocode_patches(nominatim_result): + stack.enter_context(cm) + result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") + + assert result is not None + assert result.city_ambiguous is False + + +# ── geocode() — cache не смешивает города ──────────────────────────────────── + + +async def test_geocode_cache_does_not_mix_cities() -> None: + """#2576 regression: два города для одного текста адреса не делят cache-запись. + + Без city_hint-aware ключа второй вызов (Тагил) читал бы уже закэшированный + (первым вызовом, ЕКБ) результат — координаты ЕКБ вместо Тагила. + """ + store: dict[str, GeocodeResult] = {} + + def fake_cache_get(db, addr_norm): + return store.get(addr_norm) + + def fake_cache_put(db, addr_norm, result): + store[addr_norm] = result + + async def fake_nominatim_lookup(address, city_hint=None): + if city_hint == "Нижний Тагил": + return GeocodeResult( + lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim" + ) + return GeocodeResult( + lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim" + ) + + db = MagicMock() + with contextlib.ExitStack() as stack: + stack.enter_context(patch("app.services.geocoder._cache_get", side_effect=fake_cache_get)) + stack.enter_context(patch("app.services.geocoder._cache_put", side_effect=fake_cache_put)) + stack.enter_context( + patch("app.services.geocoder._geoportal_house_match", return_value=None) + ) + stack.enter_context( + patch("app.services.geocoder._cadastral_house_match", return_value=None) + ) + stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[])) + stack.enter_context( + patch( + "app.services.geocoder._nominatim_lookup", + new=AsyncMock(side_effect=fake_nominatim_lookup), + ) + ) + + r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург") + r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") + # Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила). + r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург") + + assert r_ekb is not None and r_tagil is not None and r_ekb_again is not None + assert r_ekb.lat == pytest.approx(56.838) + assert r_tagil.lat == pytest.approx(57.905) + assert r_ekb_again.lat == pytest.approx(56.838) + assert r_ekb.lat != r_tagil.lat + # Два разных ключа реально осели в fake-store (не перезаписали друг друга). + assert len(store) == 2 diff --git a/tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py b/tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py new file mode 100644 index 00000000..b70b3c36 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_nominatim_lookup.py @@ -0,0 +1,91 @@ +"""Тесты `_nominatim_lookup` — city реально доходит до исходящего HTTP-запроса. + +#2593 (часть 3): Yandex Geocoder полностью удалён из проекта, вместе с ним ушли +`_yandex_reverse.py` + `tests/test_audit_address_mismatch.py` + +`tests/test_backfill_house_coords.py` — они были единственной проверкой, что +`city`/`city_hint` реально передаётся во внешний геокодер, а не только влияет на +cache-ключ (см. `tests/test_geocoder_city_hint.py`, который мокает +`_nominatim_lookup`/`_nominatim_suggest` целиком и потому не видит их внутренности). + +Nominatim теперь единственный живой внешний провайдер (`_nominatim_lookup` +docstring, `app/services/geocoder.py`) — этот файл закрывает получившуюся дыру: +мокает HTTP-транспорт (`httpx.MockTransport`, паттерн из `test_geocoder_bbox.py` / +`tests/services/test_dadata.py`) и проверяет параметр `q` реального исходящего +GET-запроса к `nominatim.openstreetmap.org/search`. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import patch + +import httpx + +from app.services.geocoder import _nominatim_lookup + +# EKB-центр (Плотинка) — внутри tight EKB bbox, `_nominatim_query` его примет +# без похода во второй (typo-variant) тир. +_SAMPLE_ITEM = { + "lat": "56.838", + "lon": "60.605", + "class": "building", + "display_name": "ул. Малышева, 30, Екатеринбург", + "address": {"state": "Свердловская область"}, +} + +# Snapshot реального httpx.AsyncClient ДО patch'а — фабрика ниже использует именно +# его с подменённым transport (паттерн tests/services/test_dadata.py: избегает +# recursion, если бы `httpx.AsyncClient` патчился поверх самого себя). +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def _async_client_factory(transport: httpx.MockTransport): + def factory(*_: object, **__: object) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=transport) + + return factory + + +def _capturing_transport(captured_q: list[str]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + captured_q.append(request.url.params.get("q", "")) + return httpx.Response(200, json=[_SAMPLE_ITEM]) + + return httpx.MockTransport(handler) + + +async def test_nominatim_lookup_sends_city_hint_in_query_param() -> None: + """city_hint="Нижний Тагил" должен попасть в q= реального GET-запроса. + + Регрессия, о которой явно предупреждает docstring `_nominatim_lookup` (#2580 C): + city_hint обязан влиять на сам запрос к провайдеру, не только на cache-ключ. + """ + captured_q: list[str] = [] + transport = _capturing_transport(captured_q) + + with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)): + result = await _nominatim_lookup("Ленина, 1", city_hint="Нижний Тагил") + + assert captured_q, "запрос к Nominatim не был отправлен" + assert captured_q[0] == "Нижний Тагил, Ленина, 1" + assert result is not None + assert result.provider == "nominatim" + + +async def test_nominatim_lookup_no_city_sends_bare_address() -> None: + """Без city_hint и без маркера города в тексте — q= остаётся bare-адресом. + + Guard против регрессии в молчаливый дефолт на конкретный город (#2576/#2593) + — до фикса #2576 сюда молча подставлялся "Екатеринбург". + """ + captured_q: list[str] = [] + transport = _capturing_transport(captured_q) + + with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)): + result = await _nominatim_lookup("Малышева, 30") + + assert captured_q == ["Малышева, 30"] + assert result is not None diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index c6bacef4..ef6494e0 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -106,6 +106,24 @@ def test_keeper_rule_priority_order() -> None: assert col in hdm._COMPLETENESS_EXPR +def test_keeper_listing_count_puts_nulls_last() -> None: + """«Больше объявлений» обязано означать больше, а не меньше (#2674). + + listing_cnt приходит из LEFT JOIN listing_counts → у дома БЕЗ объявлений он NULL, а `DESC` + в Postgres по умолчанию NULLS FIRST. Без явного NULLS LAST правило инвертировано: пустая + запись обгоняет запись со 192 объявлениями и забирает роль keeper'а, после чего объявления + проигравшего переезжают на дом, на который корпус никогда не ссылался, а неполный + COALESCE-перенос (год постройки / тип / этажность / застройщик не переносятся) удаляет + данные более богатой строки. RED до фикса: был голый `listing_cnt DESC`. + """ + order = _flat(hdm._KEEPER_ORDER) + assert "listing_cnt DESC NULLS LAST" in order + # Оба места, где применяется порядок (ROW_NUMBER-ранг и first_value-выбор keeper'а), + # берут одну и ту же константу — иначе ранг и keeper разъедутся построчно. + mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR, apply_geo_guard=False)) + assert mapping.count("listing_cnt DESC NULLS LAST") >= 2 + + def test_mapping_selects_losers_only() -> None: """The mapping is loser_id→keeper_id; the keeper itself is excluded (rn>1, id<>keeper).""" flat = _flat(_MAPPING_SQL) diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py new file mode 100644 index 00000000..c36aa891 --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -0,0 +1,220 @@ +"""#2674 — домовая оценка Авито перестаёт врать про ремонт и тип дома. + +Покрывает три дефекта из эпика: + 1. renovation_type берётся из моды listings.repair_state и проходит через + существующий estimator._IMV_REPAIR_MAP (был захардкожен литерал 'cosmetic': + 2685 из 2685 запросов ушли как «косметический ремонт»). + 2. Неизвестный тип дома НЕ уезжает дефолтом 'panel' (самый дешёвый класс → + системное занижение), а помечает дом и экономит запрос. Отдельно — + camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / stalin) + распознаётся, к нижнему регистру он не приводится. + 3. Прогон с saved=0 и ненулевыми errors не помечается 'done'. + +БД и сеть замоканы — реального Postgres/Авито не нужно. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib +from app.services.product_handlers import _job_house_imv_backfill + +# ── (1) renovation_type из данных через существующий маппинг ────────────────── + + +def _db_for_pick(listing_row: dict[str, Any], house_row: dict[str, Any] | None) -> MagicMock: + """MagicMock-Session: два .mappings().first() подряд (listings-агрегат, houses).""" + db = MagicMock() + db.execute.return_value.mappings.return_value.first.side_effect = [listing_row, house_row] + return db + + +def _listing_row(**over: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "rooms": 2, + "area_m2": 52.0, + "floor": 3, + "total_floors": 10, + "house_type": "panel", + "repair_state": None, + } + base.update(over) + return base + + +@pytest.mark.parametrize( + ("repair_state", "expected"), + [ + ("needs_repair", "required"), + ("standard", "cosmetic"), + ("good", "euro"), + ("excellent", "designer"), + ], +) +def test_renovation_type_comes_from_listings_via_estimator_map( + repair_state: str, expected: str +) -> None: + """Мода repair_state → renovation_type ровно по estimator._IMV_REPAIR_MAP.""" + from app.services.estimator import _IMV_REPAIR_MAP + + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=repair_state), None), 1) + + assert params["renovation_type"] == expected + # Не второй словарь: значение обязано совпадать с источником правды. + assert params["renovation_type"] == _IMV_REPAIR_MAP[repair_state] + + +def test_renovation_type_not_hardcoded_cosmetic() -> None: + """Regression #2674: 'good' больше не превращается в 'cosmetic'.""" + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state="good"), None), 1) + assert params["renovation_type"] != "cosmetic" + + +def test_unknown_repair_state_stays_cosmetic() -> None: + """Анти-оверрич: ремонт неизвестен → середина шкалы 'cosmetic', дом не теряем.""" + params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=None), None), 1) + assert params["renovation_type"] == "cosmetic" + assert params["house_type"] == "panel" # дом всё ещё пригоден к запросу + + +# ── (2) тип дома: неизвестный не врёт, camelCase распознаётся ───────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # camelCase из Циана — нижним регистром НЕ лечится (ключ канона через '_'). + ("monolithBrick", "monolithic"), + ("gasSilicateBlock", "block"), + ("aerocreteBlock", "block"), + ("stalin", "brick"), + # каноничные значения продолжают работать + ("panel", "panel"), + ("monolith", "monolithic"), + ("monolith_brick", "monolithic"), + ], +) +def test_map_house_type_recognises_camel_case(raw: str, expected: str) -> None: + assert hib._map_house_type(raw) == expected + + +@pytest.mark.parametrize("raw", [None, "", "other", "wireframe", "какая-то дичь"]) +def test_map_house_type_unknown_is_none_not_panel(raw: str | None) -> None: + """Regression #2674: нет типа / не распознан → None, а НЕ дефолт 'panel'.""" + assert hib._map_house_type(raw) is None + + +def test_pick_lot_params_unknown_house_type_yields_none() -> None: + """Типа нет ни в listings, ни в houses → house_type=None (не 'panel').""" + db = _db_for_pick(_listing_row(house_type=None), {"house_type": None, "total_floors": 9}) + assert hib.pick_lot_params(db, 1)["house_type"] is None + + +@pytest.mark.asyncio +async def test_unknown_house_type_skips_request_and_marks_house() -> None: + """Неизвестный тип → запрос к площадке НЕ уходит, дом помечен no_params.""" + params = { + "rooms": 2, + "area_m2": 52.0, + "floor": 3, + "floor_at_home": 10, + "house_type": None, + "renovation_type": "cosmetic", + "has_balcony": True, + "has_loggia": False, + } + houses = [{"id": 11, "address": "ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}] + db = MagicMock() + + with ( + patch.object(hib, "pick_lot_params", return_value=params), + patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval, + patch.object(hib, "_mark_status") as mock_mark, + ): + db.execute.return_value.mappings.return_value.all.return_value = houses + result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0) + + mock_eval.assert_not_called() + mock_mark.assert_called_once_with(db, 11, "no_params", "unknown house_type") + assert result.skipped == 1 + assert result.saved == 0 + + +# ── (3) прогон с нулём сохранённых и ошибками не «успешен» ──────────────────── + + +class _RunsRecorder: + """Duck-typed ctx.runs: пишет, чем закончился прогон.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.calls.append(("mark_done", counters)) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.calls.append(("mark_failed", counters)) + + +async def _drive_job(*, saved: int, errors: int, skipped: int = 0) -> list[tuple[str, dict]]: + runs = _RunsRecorder() + enrichment = MagicMock() + enrichment.house_imv_backfill = AsyncMock( + return_value=hib.HouseIMVBackfillResult( + checked=saved + errors + skipped, + saved=saved, + skipped=skipped, + errors=errors, + duration_sec=1.0, + ) + ) + ctx = MagicMock(runs=runs, enrichment=enrichment) + await _job_house_imv_backfill(MagicMock(), 1, {}, ctx) + return runs.calls + + +@pytest.mark.asyncio +async def test_zero_saved_with_errors_is_not_done() -> None: + """Прод-случай: 31 прогон подряд saved=0 / errors≈35 из 50 уходил в 'done'.""" + calls = await _drive_job(saved=0, errors=35, skipped=15) + assert calls[-1][0] == "mark_failed" + assert calls[-1][1]["saved"] == 0 + assert calls[-1][1]["errors"] == 35 + + +@pytest.mark.asyncio +async def test_honest_empty_stays_done() -> None: + """Анти-оверрич: ноль сохранённых без ошибок (всё в skipped) — честная пустота.""" + calls = await _drive_job(saved=0, errors=0, skipped=50) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_partial_success_stays_done() -> None: + """Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки.""" + calls = await _drive_job(saved=3, errors=7) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_counters_feed_total_seen_and_new_count() -> None: + """#2674: без этих ключей _column_counts оставляет колонку total_seen=0, + + и алерт «3 подряд done с нулевым результатом» (#2625) выстрелил бы даже на + полностью успешном прогоне. На проде так лежат все 39 прогонов source'а. + """ + from app.services.scrape_runs import _column_counts + + calls = await _drive_job(saved=50, errors=0) + counters = calls[-1][1] + assert _column_counts(counters) == (50, 50) diff --git a/tradein-mvp/backend/tests/test_identity_store.py b/tradein-mvp/backend/tests/test_identity_store.py new file mode 100644 index 00000000..cf2c69d2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_identity_store.py @@ -0,0 +1,491 @@ +"""Tests for app.services.identity_store + app.core.auth_db — эпик «единый вход». + +`identity_store` — единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ +живёт identity. Всё остальное (auth_session, rbac, роуты) спрашивает у него, и +поэтому ошибка ЗДЕСЬ — это ошибка сразу везде. + +Главное, что пинят эти тесты (⚠️ ограничение PR: после мержа прод обязан +работать ТОЧНО как сейчас): + + 1. ДЕФОЛТ = старое поведение. `IDENTITY_STORE` не задан → `tradein_users` / + `tradein_sessions`, boolean-колонка, сессия из `app.core.db.SessionLocal`. + 2. При дефолте код НЕ ТРОГАЕТ БД `auth` вообще: engine не строится, пустой + `AUTH_DATABASE_URL` не ошибка. На проде роль `auth_app` ещё без пароля и + DSN не заведён — любое обращение туда было бы отказом входа. + 3. `IDENTITY_STORE=auth` + пустой DSN → ЯВНАЯ `AuthDatabaseNotConfiguredError`, + а не тихий фолбэк на tradein-таблицы и не пустой результат. Молчаливая + деградация auth-пути читалась бы как «неверный пароль» у всех сразу. + 4. `get_identity_db` в дефолтном режиме отдаёт ТОТ ЖЕ объект `Session`, что и + `get_db` — «Команда» пишет строку сотрудника и его квоту одной транзакцией. + Регрессия здесь дала бы состояние «сотрудник создан, квота нет». + 5. Литералы значений состояния (`True`/`'active'`/...) — пин по таблице + значений, а не round-trip через `to_access_state`: инверсия + `access_state_param` обязана быть видна. +""" + +from __future__ import annotations + +import os +from typing import Annotated, Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy import Engine + +from app.core import auth_db, config +from app.core.db import get_db +from app.core.rbac import rbac_guard +from app.services import identity_store +from app.services.identity_store import ( + AccessState, + access_state_param, + get_identity_db, + identity_schema, + identity_session, + to_access_state, +) +from tests.support.identity_modes import IDENTITY_MODES, use_identity_mode + +_FAKE_AUTH_DSN = "postgresql+psycopg://auth_app:secret@localhost:5432/auth" + + +@pytest.fixture(autouse=True) +def _clean_identity_state(monkeypatch: pytest.MonkeyPatch): + """Дефолтный режим + пустой DSN + сброшенный engine до И после теста. + + Engine БД `auth` живёт в module-global, а не в `settings`, поэтому + monkeypatch его не откатывает — держим сброс явно с обеих сторон, иначе + построенный здесь engine утёк бы в любой следующий тест сьюта. + """ + auth_db.reset_auth_db() + monkeypatch.setattr(config.settings, "identity_store", "tradein") + monkeypatch.setattr(config.settings, "auth_database_url", "") + # Второй источник DSN: при пустом AUTH_DATABASE_URL он собирается из + # AUTH_DB_PASSWORD + частей (см. Settings.resolved_auth_database_url). Не + # обнули его здесь — и заданная в окружении переменная сделала бы реестр + # «сконфигурированным»: тесты про «пустой DSN → явная ошибка» позеленели бы + # мимо проверяемого поведения. + # SecretStr, а не "": поле объявлено `SecretStr`, а `validate_assignment` у + # Settings выключен — monkeypatch кладёт значение КАК ЕСТЬ, без приведения + # типа, и голая строка уронила бы резолвер на `.get_secret_value()`. + monkeypatch.setattr(config.settings, "auth_db_password", SecretStr("")) + yield + auth_db.reset_auth_db() + + +class _FakeSession: + """Session-заглушка: тестам здесь важна ИДЕНТИЧНОСТЬ объекта, не поведение.""" + + def __enter__(self) -> _FakeSession: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def close(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# identity_schema — имена, попадающие прямо в SQL +# --------------------------------------------------------------------------- + + +def test_settings_defaults_are_legacy_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """⚠️ ГЛАВНЫЙ ИНВАРИАНТ PR, пин НАПРЯМУЮ по классу настроек. + + Все остальные identity-тесты работают под autouse-фикстурой, которая + ПРИНУДИТЕЛЬНО выставляет `identity_store="tradein"` — то есть проверяют + поведение при уже выбранном режиме, а не сам дефолт. Перевернись + `Field(default=...)` в config.py — они бы этого не заметили, и прод молча + ушёл бы в БД `auth`, где ещё нет ни пароля роли `auth_app`, ни данных. + + Поэтому здесь настройки конструируются заново, минуя `config.settings`: + * `_env_file=None` — не читать локальный `.env` (дев-машина или CI могут + держать там свои значения; пиним ДЕФОЛТ КОДА, а не окружение); + * `delenv` обеих переменных — то же самое для переменных процесса. + Останется ровно то, что записано литералом в `Settings`. + """ + monkeypatch.delenv("IDENTITY_STORE", raising=False) + monkeypatch.delenv("AUTH_DATABASE_URL", raising=False) + + fresh = config.Settings(_env_file=None) # type: ignore[call-arg] + + assert fresh.identity_store == "tradein", ( + "дефолт IDENTITY_STORE обязан остаться 'tradein': прод после мержа должен " + "работать ТОЧНО как сейчас, на tradein_users/tradein_sessions" + ) + assert fresh.auth_database_url == "", ( + "AUTH_DATABASE_URL обязан быть пуст по умолчанию: на проде DSN роли " + "auth_app ещё не заведён, и пустое значение не должно ронять старт" + ) + + +def test_default_schema_is_todays_production(monkeypatch: pytest.MonkeyPatch) -> None: + """Без переменной окружения — ровно сегодняшние таблицы «Меры».""" + schema = identity_schema() + assert schema.store == "tradein" + assert schema.users_table == "tradein_users" + assert schema.sessions_table == "tradein_sessions" + assert schema.access_state_column == "is_active" + assert schema.access_state_sql_type == "boolean" + + +def test_auth_schema_points_at_shared_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """В БД `auth` таблицы без префикса продукта — реестр общий на «Меру» и «Птицу».""" + use_identity_mode(monkeypatch, "auth") + schema = identity_schema() + assert schema.store == "auth" + assert schema.users_table == "users" + assert schema.sessions_table == "sessions" + assert schema.access_state_column == "access_state" + assert schema.access_state_sql_type == "text" + + +def test_schema_is_read_per_call_not_cached_at_import(monkeypatch: pytest.MonkeyPatch) -> None: + """Флаг читается на КАЖДОМ вызове: переключение не требует перезагрузки модулей.""" + assert identity_schema().users_table == "tradein_users" + use_identity_mode(monkeypatch, "auth") + assert identity_schema().users_table == "users" + + +def test_unknown_store_raises_instead_of_silent_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """Значение вне словаря — ошибка, а не «ну возьмём tradein». + + Недостижимо через настройки (`Literal` валидируется pydantic), но молчаливый + фолбэк здесь означал бы поход не в ту БД. + """ + monkeypatch.setattr(config.settings, "identity_store", "elsewhere") + with pytest.raises(ValueError, match="elsewhere"): + identity_schema() + + +@pytest.mark.parametrize("mode", IDENTITY_MODES) +def test_table_names_never_come_from_outside(monkeypatch: pytest.MonkeyPatch, mode: str) -> None: + """Имена таблиц — только из фиксированного словаря (защита от SQL-инъекции по имени). + + Имя таблицы нельзя передать bind-параметром, оно склеивается в строку запроса, + поэтому единственный допустимый источник — `_SCHEMAS`. Тест пинит, что весь + набор значений конечен и не содержит ничего, кроме идентификаторов. + """ + use_identity_mode(monkeypatch, mode) + schema = identity_schema() + for name in (schema.users_table, schema.sessions_table, schema.access_state_column): + assert name.replace("_", "").isalnum(), name + assert schema.access_state_sql_type in ("boolean", "text") + + +# --------------------------------------------------------------------------- +# AccessState / to_access_state — ОДНО понятие состояния на обе схемы +# --------------------------------------------------------------------------- + + +def test_only_active_can_sign_in() -> None: + assert AccessState.ACTIVE.can_sign_in is True + assert AccessState.TRIAL_EXPIRED.can_sign_in is False + assert AccessState.DISABLED.can_sign_in is False + + +def test_boolean_column_maps_to_active_disabled() -> None: + """Булев `tradein_users.is_active` — ровно два состояния, `trial_expired` там нет.""" + assert to_access_state(True) is AccessState.ACTIVE + assert to_access_state(False) is AccessState.DISABLED + + +def test_text_column_maps_by_value() -> None: + assert to_access_state("active") is AccessState.ACTIVE + assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED + assert to_access_state("disabled") is AccessState.DISABLED + + +@pytest.mark.parametrize("value", ["", "ACTIVE", "pending_review", None, 1, 0, object()]) +def test_unrecognized_state_is_fail_closed(value: object) -> None: + """Неизвестное значение / NULL / неожиданный тип → `disabled`. + + Обратный выбор («пускать всё, что не disabled») означал бы, что состояние, + добавленное миграцией РАНЬШЕ кода, молча раздаёт доступ. NB: `1`/`0` — это + int, а не bool, и в булевой схеме они не появляются; сюда они попадают как + «неожиданный тип» и тоже блокируются. + """ + assert to_access_state(value) is AccessState.DISABLED + + +# --------------------------------------------------------------------------- +# access_state_param — обратное направление (ЗАПИСЬ) +# --------------------------------------------------------------------------- + + +def test_write_value_in_boolean_schema() -> None: + """Литералы, а не round-trip: инверсия функции обязана быть видна прямо здесь.""" + assert access_state_param(AccessState.ACTIVE) is True + assert access_state_param(AccessState.DISABLED) is False + + +def test_write_value_in_text_schema(monkeypatch: pytest.MonkeyPatch) -> None: + use_identity_mode(monkeypatch, "auth") + assert access_state_param(AccessState.ACTIVE) == "active" + assert access_state_param(AccessState.TRIAL_EXPIRED) == "trial_expired" + assert access_state_param(AccessState.DISABLED) == "disabled" + + +def test_trial_expired_is_not_silently_downgraded_in_boolean_schema() -> None: + """`trial_expired` в булевой схеме — ошибка вызывающего, НЕ тихий `False`. + + Тихое приведение превратило бы «пробный период истёк» в жёсткую блокировку: + клиент увидел бы «неверный логин или пароль» вместо экрана пробного периода. + """ + with pytest.raises(ValueError, match="trial_expired"): + access_state_param(AccessState.TRIAL_EXPIRED) + + +# --------------------------------------------------------------------------- +# Где физически берётся сессия реестра +# --------------------------------------------------------------------------- + + +def test_default_mode_uses_product_session_and_never_builds_auth_engine( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Дефолт: та же `SessionLocal`, что у всего приложения; БД `auth` не трогается. + + Это буквально «прод после мержа работает как сейчас»: `AUTH_DATABASE_URL` на + проде пуст, и его отсутствие не должно ни ронять старт, ни всплывать в + рантайме. + """ + opened: list[_FakeSession] = [] + + def _session_local() -> _FakeSession: + s = _FakeSession() + opened.append(s) + return s + + monkeypatch.setattr(identity_store, "SessionLocal", _session_local) + + with identity_session() as db: + assert db is opened[0] + + assert len(opened) == 1 + assert auth_db._engine is None + assert auth_db._session_factory is None + + +def test_auth_mode_without_dsn_raises_instead_of_silent_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`IDENTITY_STORE=auth` + пустой DSN → явная ошибка, и НИ ОДНОГО запроса в tradein. + + Тихий фолбэк на `tradein_users` был бы худшим исходом: вход бы «работал», но + в реестре, который к тому моменту считается неактуальным. + """ + use_identity_mode(monkeypatch, "auth") + + def _must_not_be_called() -> _FakeSession: + raise AssertionError("режим auth не имеет права открывать сессию БД tradein") + + monkeypatch.setattr(identity_store, "SessionLocal", _must_not_be_called) + + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError, match="AUTH_DATABASE_URL"): + with identity_session(): + pass + + +def test_auth_engine_is_lazy_cached_and_resettable(monkeypatch: pytest.MonkeyPatch) -> None: + """Engine строится при ПЕРВОМ обращении, кешируется, сбрасывается `reset_auth_db`. + + `create_engine` к серверу не ходит (пул ленивый), поэтому тест не требует + живой БД — проверяется именно кеширование, из-за которого два одновременных + первых запроса иначе создали бы два независимых пула. + """ + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr(config.settings, "auth_database_url", _FAKE_AUTH_DSN) + + assert auth_db._engine is None # ленивость: до первого обращения ничего нет + engine = auth_db.get_auth_engine() + assert isinstance(engine, Engine) + assert auth_db.get_auth_engine() is engine + assert auth_db.get_auth_session_factory() is auth_db.get_auth_session_factory() + + auth_db.reset_auth_db() + assert auth_db._engine is None + assert auth_db.get_auth_engine() is not engine + + +def test_blank_dsn_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: + """DSN из одних пробелов = не задан (иначе `create_engine('')` дал бы мутную ошибку).""" + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr(config.settings, "auth_database_url", " ") + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError): + auth_db.get_auth_engine() + + +# --------------------------------------------------------------------------- +# get_identity_db — FastAPI-зависимость: ОДНА транзакция в дефолте, две в auth +# --------------------------------------------------------------------------- + + +def _probe_app() -> FastAPI: + """Мини-приложение с обеими зависимостями сразу — как у роутов «Команды».""" + app = FastAPI() + + @app.get("/probe") + async def probe( + db: Annotated[Any, Depends(get_db)], + identity_db: Annotated[Any, Depends(get_identity_db)], + ) -> dict[str, bool]: + return {"same_session": db is identity_db} + + return app + + +def test_default_mode_shares_one_session_with_get_db(monkeypatch: pytest.MonkeyPatch) -> None: + """`db is identity_db` в дефолте — не экономия коннекта, а требование прода. + + «Команда» пишет строку сотрудника (реестр) и его квоту (`account_quota_overrides`, + продуктовая таблица) В ОДНОЙ транзакции. Две сессии = две транзакции = + состояние «сотрудник создан, квота нет» на ровном месте. + """ + app = _probe_app() + app.dependency_overrides[get_db] = lambda: iter([_FakeSession()]) + + resp = TestClient(app).get("/probe") + + assert resp.status_code == 200, resp.text + assert resp.json() == {"same_session": True} + + +def test_auth_mode_yields_separate_registry_session(monkeypatch: pytest.MonkeyPatch) -> None: + """В режиме `auth` БД физически разные → и сессии обязаны быть разными объектами. + + `db is not identity_db` — рантайм-признак «БД разные», по которому `team.py` + решает, коммитить ли вторую транзакцию. + """ + use_identity_mode(monkeypatch, "auth") + registry_session = _FakeSession() + + from contextlib import contextmanager + + @contextmanager + def _fake_auth_session(): + yield registry_session + + monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session) + + app = _probe_app() + app.dependency_overrides[get_db] = lambda: iter([_FakeSession()]) + + resp = TestClient(app).get("/probe") + + assert resp.status_code == 200, resp.text + assert resp.json() == {"same_session": False} + + +# --------------------------------------------------------------------------- +# Сломанная конфигурация не роняет запрос (rbac_guard) +# --------------------------------------------------------------------------- + + +def _guarded_app() -> FastAPI: + app = FastAPI() + app.middleware("http")(rbac_guard) + + @app.get("/api/v1/trade-in/dummy") + async def dummy() -> dict[str, bool]: + return {"ok": True} + + return app + + +def test_misconfigured_auth_store_degrades_to_401_not_500( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`IDENTITY_STORE=auth` без DSN + запрос С КУКОЙ → 401, а не 500. + + `AuthDatabaseNotConfiguredError` обрабатывается тем же путём, что и любой + сбой БД: резолв сессии не состоялся, дальше решает `auth_mode`. Сознательно + не отличается от «БД недоступна» — обе ситуации это сломанная конфигурация + реестра, и ни одна не имеет права отдавать 500 (или, тем более, пускать). + """ + use_identity_mode(monkeypatch, "auth") + client = TestClient(_guarded_app(), base_url="https://testserver") + client.cookies.set(config.settings.session_cookie_name, "some-token") + + resp = client.get("/api/v1/trade-in/dummy") + + assert resp.status_code == 401 + # Legacy trusted-header путь (dual-mode) при этом продолжает работать — + # сломанный реестр не отрезает существующих пользователей Caddy. + # + # ⚠️ Это поведение УЖЕ НЕДОСТИЖИМО в реальном процессе: до такого состояния + # приложение не доживает, потому что lifespan падает на старте (см. + # `test_lifespan_fails_fast_when_auth_store_has_no_dsn` ниже). Тест держит + # guard'а от 500-ки/анонимного доступа как второй рубеж — на случай, если + # DSN сломается уже ПОСЛЕ успешного старта. + fallback = client.get("/api/v1/trade-in/dummy", headers={"X-Authenticated-User": "kopylov"}) + assert fallback.status_code == 200, fallback.text + + +# --------------------------------------------------------------------------- +# Boot-time guard: сломанный реестр не должен ЖИТЬ на legacy-пути +# --------------------------------------------------------------------------- + + +def _run_lifespan(monkeypatch: pytest.MonkeyPatch) -> None: + """Прогоняет lifespan приложения до `yield` и обратно. + + FDW-bootstrap выключен: он ходит в продуктовую БД, которой в юнит-тестах + нет. К проверяемому здесь он отношения не имеет (и в самом lifespan обёрнут + в try/except), а без заглушки тест ждал бы таймаута коннекта. + """ + import asyncio + + from app import main as app_main + + monkeypatch.setattr(app_main, "ensure_fdw_user_mapping", lambda db: None) + + async def _cycle() -> None: + async with app_main.lifespan(app_main.app): + pass + + asyncio.run(_cycle()) + + +def test_lifespan_fails_fast_when_auth_store_has_no_dsn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`IDENTITY_STORE=auth` + пустой DSN → контейнер НЕ поднимается. + + Почему не «работает как-нибудь»: `rbac_guard` ловит + `AuthDatabaseNotConfiguredError` вместе с любым другим сбоем резолва сессии + и уходит в legacy trusted-header ветку. Продуктовая БД при этом жива, и + такой деплой способен работать сутками, раздавая права из roles.yaml всем, + кого пропустил Caddy basic_auth, — включая аккаунты, у которых в реестре + `access_state='disabled'`/`'trial_expired'`. Ошибка КОНФИГУРАЦИИ обязана + убивать старт, а не деградировать в тихий обход реестра. + """ + use_identity_mode(monkeypatch, "auth") + monkeypatch.setattr(config.settings, "auth_database_url", "") + + with pytest.raises(auth_db.AuthDatabaseNotConfiguredError): + _run_lifespan(monkeypatch) + + +def test_lifespan_does_not_touch_auth_db_in_default_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Дефолтный режим: старт НЕ обращается к БД `auth` и пустой DSN не мешает. + + Ровно ограничение PR — сегодняшний прод (`IDENTITY_STORE` не задан, + `AUTH_DATABASE_URL` нет вовсе) обязан подниматься как раньше. + """ + from app import main as app_main + + calls: list[str] = [] + monkeypatch.setattr(app_main, "get_auth_engine", lambda: calls.append("built")) + + _run_lifespan(monkeypatch) + + assert calls == [], "в дефолтном режиме engine БД `auth` не должен строиться на старте" diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py index 41627db1..6c19c9e5 100644 --- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py @@ -1,15 +1,36 @@ """Тесты browser-пула для kit `scraper_kit.browser_fetcher.BrowserFetcher` (#2164 P4). -Инвариант ship-dark + fallback: +Sticky-session fix (живая регрессия 2026-08): раньше `BrowserFetcher` брал/отпускал +lease из пула НА КАЖДЫЙ `/fetch` — при N>=2 живых узлах пула `acquire()` (ORDER BY +last_ok_at NULLS LAST — «давно не использованный первый») гарантированно выдавал +ДРУГОЙ прокси на каждый вызов, а tradein-browser релончит camoufox при каждой смене +желаемого прокси (см. server.py `_ensure_browser`) — 17 relaunch'ей за 15 минут в +проде. Фикс — ОДИН lease на весь жизненный цикл фетчера (см. __aenter__/__aexit__). + +Инвариант ship-dark/fallback (не изменился): - use_pool=False (дефолт) → в теле POST /fetch НЕТ поля "proxy"; proxy_provider не трогается (golden-parity: браузер юзает свой env-прокси BROWSER_PROXY_*). - use_pool=True + пул выдал lease → тело содержит "proxy"=lease.url + "proxy_kind"; на выходе mark_health(ok) + release (в finally — lease не течёт). - - use_pool=True + пул пуст (acquire→None) → тела без "proxy", НЕ падаем. - - use_pool=True + acquire бросил → fallback без "proxy", НЕ падаем. + - use_pool=True + пул пуст (acquire→None) + dev (дефолт) → тело без "proxy", НЕ падаем. + - use_pool=True + пул пуст (acquire→None) + prod (#2616 шаг 1) → NoProxyAvailableError + на initial acquire (__aenter__) ИЛИ на ре-acquire mid-run (осознанная ротация после + N подряд провалов) — POST /fetch НЕ отправляется вовсе (ни на входе, ни после отказа). + - use_pool=True + acquire бросил → fallback без "proxy" (dev), НЕ падаем. - fetch кинул → mark_health(ok=False) + release всё равно (finally). -httpx полностью замокан: fetcher._client подменяется MagicMock'ом. +Новый инвариант (sticky session, #2164 sticky-session fix): + - acquire() вызывается РОВНО ОДИН раз — в __aenter__, а НЕ на каждый /fetch. + - Несколько /fetch подряд в одной сессии несут ОДИН и тот же lease.url. + - release() — один раз в __aexit__ (finally — lease не течёт даже при исключении + внутри `async with`-блока). + - mark_health + touch (heartbeat, продлевает leased_at — см. proxy_pool.touch) — + на КАЖДЫЙ /fetch (успешный и неуспешный), та же грануляция, что была раньше. + - N подряд неудачных /fetch (_LEASE_ROTATE_AFTER_FAILS) → lease ОСОЗНАННО меняется + один раз (release старого + acquire нового), счётчик обнуляется; успех сбрасывает + счётчик до этого порога. + +httpx полностью замокан: fetcher._client подменяется MagicMock'ом после __aenter__. """ from __future__ import annotations @@ -20,6 +41,16 @@ from unittest.mock import AsyncMock, MagicMock import pytest from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.contracts import ProxyLease +from scraper_kit.proxy_errors import NoProxyAvailableError + +# _LEASE_ROTATE_AFTER_FAILS не существовал до sticky-session фикса (#2164, 2026-08) — +# импортируем с фолбэком, чтобы ImportError не ронял КОЛЛЕКЦИЮ всего модуля (маскируя +# value-based падения остальных тестов на pre-fix коде). Тесты, которым он реально +# нужен (rotate-after-N-fails), падают на своём собственном месте, если его нет. +try: + from scraper_kit.browser_fetcher import _LEASE_ROTATE_AFTER_FAILS +except ImportError: + _LEASE_ROTATE_AFTER_FAILS = 3 # dummy — тесты ниже провалятся по существу, не по импорту def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock: @@ -32,24 +63,36 @@ def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = resp.json.return_value = json_payload client = MagicMock() client.post = AsyncMock(return_value=resp) + client.aclose = AsyncMock(return_value=None) # __aexit__ awaits это при закрытии сессии return client class _FakeProxyProvider: - """ProxyProvider-заглушка: acquire отдаёт заданный lease (или None), считает вызовы.""" + """ProxyProvider-заглушка: acquire отдаёт lease(ы) по очереди, считает вызовы.""" - def __init__(self, lease: ProxyLease | None, *, acquire_raises: bool = False) -> None: - self._lease = lease + def __init__( + self, + leases: ProxyLease | None | list[ProxyLease | None], + *, + acquire_raises: bool = False, + ) -> None: + self._queue: list[ProxyLease | None] = ( + list(leases) if isinstance(leases, list) else [leases] + ) self._acquire_raises = acquire_raises self.acquired: list[str] = [] self.released: list[int] = [] self.health: list[tuple[int, bool]] = [] + self.touched: list[int] = [] + self.banned: list[tuple[int, str]] = [] def acquire(self, provider: str) -> ProxyLease | None: self.acquired.append(provider) if self._acquire_raises: raise RuntimeError("pool boom") - return self._lease + if self._queue: + return self._queue.pop(0) + return None def release(self, lease: ProxyLease) -> None: self.released.append(lease.id) @@ -57,64 +100,60 @@ class _FakeProxyProvider: def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None: self.health.append((lease.id, ok)) + def touch(self, lease: ProxyLease) -> None: + self.touched.append(lease.id) -def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher: + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + self.banned.append((lease.id, source)) + + +async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher: + """Реально входит в `__aenter__` (триггерит lease-acquire), потом подменяет httpx-клиент.""" bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs) + await bf.__aenter__() bf._client = client return bf +# ── ship-dark / fallback parity (не изменилось) ─────────────────────────────── + + async def test_fetch_pool_off_no_proxy_in_body() -> None: - """use_pool=False (дефолт) → тело без 'proxy', provider не трогается (parity).""" + """use_pool=False (дефолт) → тело без 'proxy', provider не трогается вообще (parity).""" provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http")) client = _mock_client({"html": ""}) - bf = _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False + bf = await _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False html = await bf.fetch("https://avito.ru/x") assert html == "" body = client.post.call_args.kwargs["json"] assert "proxy" not in body - assert provider.acquired == [] # пул не трогали + assert provider.acquired == [] # пул не трогали даже в __aenter__ -async def test_fetch_pool_on_injects_proxy_and_releases() -> None: - """use_pool=True + lease → 'proxy'/'proxy_kind' в теле; mark_health(True)+release.""" - lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http") - provider = _FakeProxyProvider(lease) - client = _mock_client({"html": ""}) - bf = _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) - - await bf.fetch("https://avito.ru/x") - - body = client.post.call_args.kwargs["json"] - assert body["proxy"] == "http://u:p@pool:8080" - assert body["proxy_kind"] == "http" - assert provider.acquired == ["avito"] - assert provider.health == [(7, True)] - assert provider.released == [7] - - -async def test_fetch_pool_empty_falls_back_no_proxy() -> None: - """use_pool=True + пул пуст (acquire→None) → тело без 'proxy', без mark_health/release.""" +async def test_empty_pool_falls_back_no_proxy_ever() -> None: + """use_pool=True + пул пуст (acquire→None в __aenter__) → без 'proxy', НЕ падаем.""" provider = _FakeProxyProvider(None) client = _mock_client({"html": ""}) - bf = _fetcher(client, source="cian", proxy_provider=provider, use_pool=True) + bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True) - await bf.fetch("https://cian.ru/x") + await bf.fetch("https://cian.ru/1") + await bf.fetch("https://cian.ru/2") body = client.post.call_args.kwargs["json"] assert "proxy" not in body - assert provider.acquired == ["cian"] + assert provider.acquired == ["cian"] # одна попытка (в __aenter__), не на каждый fetch assert provider.released == [] assert provider.health == [] + assert provider.touched == [] -async def test_fetch_pool_acquire_error_falls_back() -> None: - """acquire бросил → fallback без 'proxy', сбор НЕ падает.""" +async def test_acquire_error_in_aenter_falls_back() -> None: + """acquire в __aenter__ бросил → fallback без 'proxy', сбор НЕ падает.""" provider = _FakeProxyProvider(None, acquire_raises=True) client = _mock_client({"html": ""}) - bf = _fetcher(client, source="yandex", proxy_provider=provider, use_pool=True) + bf = await _fetcher(client, source="yandex", proxy_provider=provider, use_pool=True) html = await bf.fetch("https://yandex.ru/x") @@ -124,27 +163,189 @@ async def test_fetch_pool_acquire_error_falls_back() -> None: assert provider.released == [] -async def test_fetch_error_marks_health_false_and_releases() -> None: - """fetch кинул → mark_health(ok=False) + release всё равно (finally, lease не течёт).""" +# ── sticky session: ОДИН lease на весь жизненный цикл (ключевой фикс) ───────── + + +async def test_acquire_happens_once_in_aenter_not_per_fetch() -> None: + """acquire() вызывается РОВНО один раз при входе в сессию, до первого /fetch.""" + lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + assert provider.acquired == ["avito"] # уже случилось в __aenter__ + + await bf.fetch("https://avito.ru/x") + body = client.post.call_args.kwargs["json"] + assert body["proxy"] == "http://u:p@pool:8080" + assert body["proxy_kind"] == "http" + assert provider.acquired == ["avito"] # fetch НЕ вызвал acquire повторно + + +async def test_two_sequential_fetches_use_same_proxy() -> None: + """Два /fetch подряд в одной сессии несут ОДИН и тот же lease.url (было: КАЖДЫЙ + /fetch мог получить ДРУГОЙ прокси — camoufox релончился на каждый запрос).""" + lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + + await bf.fetch("https://avito.ru/1") + body1 = client.post.call_args.kwargs["json"] + await bf.fetch("https://avito.ru/2") + body2 = client.post.call_args.kwargs["json"] + + assert body1["proxy"] == body2["proxy"] == "http://u:p@pool:8080" + assert provider.acquired == ["avito"] # ровно один acquire на всю сессию + + +async def test_lease_released_on_session_close() -> None: + """release() случается один раз, в __aexit__ — не раньше (внутри сессии lease жив).""" + lease = ProxyLease(id=7, url="http://pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + + bf = BrowserFetcher( + endpoint="http://browser:3000", source="avito", proxy_provider=provider, use_pool=True + ) + async with bf: + bf._client = client + await bf.fetch("https://avito.ru/x") + assert provider.released == [] # ещё держим lease внутри сессии + + assert provider.released == [7] # __aexit__ отпустил + + +async def test_lease_released_on_exception_inside_session() -> None: + """Исключение внутри `async with`-блока (бизнес-код упал) → __aexit__ всё равно + вызывается Python'ом → lease освобождён, НЕ течёт.""" + lease = ProxyLease(id=9, url="http://pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + + with pytest.raises(RuntimeError, match="business logic exploded"): + async with BrowserFetcher( + endpoint="http://browser:3000", source="avito", proxy_provider=provider, use_pool=True + ) as bf: + bf._client = client + raise RuntimeError("business logic exploded mid-session") + + assert provider.released == [9] + + +# ── mark_health / touch — на каждый /fetch (грануляция сохранена) ───────────── + + +async def test_mark_health_and_touch_called_per_fetch() -> None: + """mark_health + touch(heartbeat) идут на КАЖДЫЙ /fetch — та же грануляция, что + была на per-fetch acquire/release раньше (не огрублена до «раз в сессию»).""" + lease = ProxyLease(id=5, url="http://pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + + await bf.fetch("https://avito.ru/1") + await bf.fetch("https://avito.ru/2") + await bf.fetch("https://avito.ru/3") + + assert provider.health == [(5, True), (5, True), (5, True)] + assert provider.touched == [5, 5, 5] + + +async def test_fetch_error_marks_health_false_but_keeps_lease_for_session() -> None: + """fetch кинул → mark_health(False) + touch, но lease НЕ освобождается сразу — он + держится до конца сессии (release только в __aexit__ или при осознанной ротации).""" lease = ProxyLease(id=3, url="http://pool:8080", kind="http") provider = _FakeProxyProvider(lease) # raise_for_status кидает НЕ-httpx ошибку → fetch() не ретраит, пробрасывает наверх. client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status")) - bf = _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) with pytest.raises(ValueError): await bf.fetch("https://avito.ru/x") assert provider.health == [(3, False)] - assert provider.released == [3] + assert provider.touched == [3] + assert provider.released == [] # сессия ещё жива — lease держим -async def test_fetch_json_pool_on_injects_proxy() -> None: - """fetch_json тоже прокидывает proxy из пула в тело /fetch-json.""" +# ── осознанная ротация после N подряд провалов (не мечемся на каждый fetch) ─── + + +async def test_lease_not_rotated_before_threshold() -> None: + """N-1 подряд провалов — ЕЩЁ недостаточно для смены lease.""" + lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http") + lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http") + provider = _FakeProxyProvider([lease1, lease2]) + client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status")) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + + for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1): + with pytest.raises(ValueError): + await bf.fetch("https://avito.ru/x") + + assert provider.released == [] # ещё не порог + assert provider.acquired == ["avito"] # всё ещё только __aenter__-acquire + + +async def test_lease_rotates_once_after_consecutive_failures() -> None: + """Ровно `_LEASE_ROTATE_AFTER_FAILS` подряд неудач → release+acquire РОВНО один + раз (не на каждый fetch). Следующий /fetch уже несёт НОВЫЙ прокси.""" + lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http") + lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http") + provider = _FakeProxyProvider([lease1, lease2]) + fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status")) + bf = await _fetcher(fail_client, source="avito", proxy_provider=provider, use_pool=True) + + for _ in range(_LEASE_ROTATE_AFTER_FAILS): + with pytest.raises(ValueError): + await bf.fetch("https://avito.ru/x") + + assert provider.released == [1] # старый lease отпущен ровно один раз + assert provider.acquired == ["avito", "avito"] # __aenter__ + одна ротация — не N+1 + + ok_client = _mock_client({"html": ""}) + bf._client = ok_client + await bf.fetch("https://avito.ru/y") + body = ok_client.post.call_args.kwargs["json"] + assert body["proxy"] == "http://pool2:8080" # уже НОВЫЙ lease + + +async def test_success_resets_fail_streak() -> None: + """Успех между провалами сбрасывает счётчик — (N-1) провал + успех + (N-1) провал + НЕ должны суммироваться в ротацию (иначе редкие транзиентные ошибки на в целом + здоровом прокси гоняли бы ротацию так же часто, как раньше гоняли relaunch).""" + lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http") + lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http") + provider = _FakeProxyProvider([lease1, lease2]) + ok_client = _mock_client({"html": ""}) + bf = await _fetcher(ok_client, source="avito", proxy_provider=provider, use_pool=True) + + fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status")) + bf._client = fail_client + for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1): + with pytest.raises(ValueError): + await bf.fetch("https://avito.ru/x") + + bf._client = ok_client + await bf.fetch("https://avito.ru/y") # успех — сбрасывает streak + + bf._client = fail_client + for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1): + with pytest.raises(ValueError): + await bf.fetch("https://avito.ru/z") + + assert provider.released == [] # порог так и не достигнут подряд + + +# ── fetch_json — тот же sticky-session инвариант ────────────────────────────── + + +async def test_fetch_json_pool_on_injects_proxy_from_session_lease() -> None: lease = ProxyLease(id=9, url="http://pool:8080", kind="http") provider = _FakeProxyProvider(lease) client = _mock_client({"status": 200, "body": "{}"}) - bf = _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) await bf.fetch_json( "https://avito.ru/api", method="POST", body="{}", origin="https://avito.ru/" @@ -153,13 +354,15 @@ async def test_fetch_json_pool_on_injects_proxy() -> None: body = client.post.call_args.kwargs["json"] assert body["proxy"] == "http://pool:8080" assert body["proxy_kind"] == "http" - assert provider.released == [9] + assert provider.touched == [9] + assert provider.health == [(9, True)] + assert provider.released == [] # сессия ещё жива async def test_fetch_json_pool_off_no_proxy() -> None: """use_pool=False → fetch_json без 'proxy' в теле (parity).""" client = _mock_client({"status": 200, "body": "{}"}) - bf = _fetcher(client, source="avito") + bf = await _fetcher(client, source="avito") await bf.fetch_json("https://avito.ru/api") @@ -167,6 +370,139 @@ async def test_fetch_json_pool_off_no_proxy() -> None: assert "proxy" not in body +# ── #2616 шаг 1: пул пуст в prod → отказ, НЕ мёртвый env-фолбэк ──────────────── +# +# Sticky-session fix переместил acquire с "на каждый /fetch" на "__aenter__ (+ ре-acquire +# на осознанной ротации после N подряд провалов)" — guard живёт в _acquire_lease(), общей +# для ОБОИХ call-site'ов, так что отказ теперь может случиться либо при входе в сессию +# (_fetcher/__aenter__), либо mid-run (внутри fetch(), при ротации). + + +async def test_fetch_pool_empty_dev_falls_back_no_proxy() -> None: + """Пул пуст + dev (дефолт environment) → прежнее поведение: тело без 'proxy'.""" + provider = _FakeProxyProvider(None) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True) + + html = await bf.fetch("https://cian.ru/x") + + assert html == "" + body = client.post.call_args.kwargs["json"] + assert "proxy" not in body + assert provider.acquired == ["cian"] + + +async def test_fetch_pool_empty_prod_refuses_on_initial_acquire() -> None: + """Пул пуст + prod на INITIAL acquire (__aenter__) → NoProxyAvailableError, POST + /fetch НЕ отправляется вовсе — отказ случается ДО входа в сессию. + + client.post настроен падать AssertionError на ЛЮБОМ вызове — если бы код тихо + зафолбэчился (регрессия), тест упал бы с несовпадающим типом исключения, а не + просто "прошёл иначе" (falsifiable). + """ + provider = _FakeProxyProvider(None) + client = MagicMock() + client.post = AsyncMock(side_effect=AssertionError("POST /fetch must NOT happen")) + + with pytest.raises(NoProxyAvailableError): + await _fetcher( + client, + source="avito", + proxy_provider=provider, + use_pool=True, + environment="production", + ) + + client.post.assert_not_called() + assert provider.acquired == ["avito"] + assert provider.released == [] + assert provider.health == [] + + +async def test_fetch_json_pool_empty_prod_refuses_on_initial_acquire() -> None: + """Та же гарантия для fetch_json-путей: prod + пул пуст на __aenter__ → отказ, без + POST (guard общий для fetch/fetch_json — обе идут через один и тот же lease).""" + provider = _FakeProxyProvider(None) + client = MagicMock() + client.post = AsyncMock(side_effect=AssertionError("POST /fetch-json must NOT happen")) + + with pytest.raises(NoProxyAvailableError): + await _fetcher( + client, + source="cian", + proxy_provider=provider, + use_pool=True, + environment="production", + ) + + client.post.assert_not_called() + + +async def test_fetch_pool_lease_prod_unaffected() -> None: + """Пул выдал lease в prod — поведение БЕЗ ИЗМЕНЕНИЙ (это не случай отказа).""" + lease = ProxyLease(id=11, url="http://u:p@pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + bf = await _fetcher( + client, source="avito", proxy_provider=provider, use_pool=True, environment="production" + ) + + html = await bf.fetch("https://avito.ru/x") + + assert html == "" + body = client.post.call_args.kwargs["json"] + assert body["proxy"] == lease.url + assert provider.health == [(11, True)] + assert provider.released == [] # сессия ещё жива — release только в __aexit__ + + +async def test_fetch_pool_empties_mid_run_prod_refuses_on_rotate_reacquire() -> None: + """Пул опустел НА РЕ-ACQUIRE mid-run (осознанная ротация после N подряд провалов) + в prod → тот же отказ NoProxyAvailableError, а НЕ тихий переход на без-proxy. + + Единственный lease выдаётся на initial acquire; на N-й подряд провал ротация + release()'ит его и повторно зовёт acquire() — очередь уже пуста → guard в + _acquire_lease() поднимает NoProxyAvailableError вместо возврата None. Он + вытесняет исходный ValueError (chained via __context__, не проглочен). + """ + lease = ProxyLease(id=1, url="http://pool1:8080", kind="http") + provider = _FakeProxyProvider([lease]) # ре-acquire после ротации застанет пустой пул + fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status")) + bf = await _fetcher( + fail_client, + source="avito", + proxy_provider=provider, + use_pool=True, + environment="production", + ) + + for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1): + with pytest.raises(ValueError): + await bf.fetch("https://avito.ru/x") + + # N-й подряд провал: ValueError → _report_fetch_result(False) → порог достигнут → + # release(1) + re-acquire → пул пуст + prod → NoProxyAvailableError вместо ValueError. + with pytest.raises(NoProxyAvailableError): + await bf.fetch("https://avito.ru/x") + + assert provider.released == [1] # старый lease отпущен ровно один раз + assert provider.acquired == ["avito", "avito"] # __aenter__ + одна попытка ротации + assert bf._lease is None # #2616: не залипает на уже отпущенном lease + + +def test_no_proxy_error_distinguishable_from_site_block() -> None: + """NoProxyAvailableError — отдельный тип с provider-атрибутом, текст без + "blocked"/"captcha"/"banned" — programmatically отличим от бана площадкой. + """ + err = NoProxyAvailableError("avito") + assert err.provider == "avito" + lowered = str(err).lower() + assert "blocked" not in lowered + assert "captcha" not in lowered + assert "banned" not in lowered + assert "no proxy available" in lowered + + # ── fetch() origin passthrough (DomClick SERP-anchor, зеркалит fetch_json) ─────── @@ -177,7 +513,7 @@ async def test_fetch_without_origin_sends_none() -> None: трактует None как «origin-goto не выполнять», поведение не меняется. """ client = _mock_client({"html": ""}) - bf = _fetcher(client, source="avito") + bf = await _fetcher(client, source="avito") html = await bf.fetch("https://avito.ru/x") @@ -189,7 +525,7 @@ async def test_fetch_without_origin_sends_none() -> None: async def test_fetch_with_origin_sends_it_in_payload() -> None: """fetch(url, origin=X) → payload несёт origin=X (domclick vtorichka-SERP anchor).""" client = _mock_client({"html": ""}) - bf = _fetcher(client, source="cian") + bf = await _fetcher(client, source="cian") await bf.fetch( "https://ekaterinburg.domclick.ru/card/sale__flat__1", @@ -210,7 +546,7 @@ async def test_fetch_without_cookies_sends_none() -> None: (body.get("cookies")) трактует None как «инъекции не делать», поведение не меняется. """ client = _mock_client({"html": ""}) - bf = _fetcher(client, source="avito") + bf = await _fetcher(client, source="avito") html = await bf.fetch("https://avito.ru/x") @@ -222,7 +558,7 @@ async def test_fetch_without_cookies_sends_none() -> None: async def test_fetch_with_cookies_sends_them_in_payload() -> None: """fetch(url, cookies=X) → payload несёт cookies=X (DomClick session cookie-injection).""" client = _mock_client({"html": ""}) - bf = _fetcher(client, source="cian") + bf = await _fetcher(client, source="cian") session_cookies = {"CAS_ID": "12345", "qrator_jsid2": "abc"} await bf.fetch( @@ -232,3 +568,75 @@ async def test_fetch_with_cookies_sends_them_in_payload() -> None: body = client.post.call_args.kwargs["json"] assert body["cookies"] == session_cookies + + +# ── report_ban — довести сигнал бана до пула (#2600 п.1) ────────────────────── +# +# Root cause: fetch() успешно вернул HTML (HTTP 200) — уже отчитался +# mark_health(ok=True). Бан-заглушка распознаётся ПОЗЖЕ, при разборе содержимого, +# провайдерским кодом (avito serp.py / cian _extract_state_tracked / yandex gate / +# domclick QRATOR-маркеры). report_ban — публичный способ явно переопределить тот +# ошибочный ok=True сигнал: помечает ТЕКУЩИЙ lease забаненным в пуле, пока lease +# ещё жив (до release/__aexit__). + + +async def test_report_ban_calls_provider_mark_banned_with_current_lease() -> None: + lease = ProxyLease(id=7, url="http://pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + + bf.report_ban("content-block suspected") + + assert provider.banned == [(7, "avito")] + + +async def test_report_ban_noop_without_lease() -> None: + """use_pool=False (дефолт) → lease нет → report_ban не трогает provider вообще.""" + provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http")) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False + + bf.report_ban("should be no-op") + + assert provider.banned == [] + + +async def test_report_ban_noop_without_provider() -> None: + """proxy_provider=None (дефолт) → report_ban не падает (best-effort).""" + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito") + + bf.report_ban("no provider configured") # не должно бросить + + +async def test_report_ban_survives_provider_exception() -> None: + """mark_banned упал внутри провайдера — report_ban не пробрасывает (best-effort, + как touch/mark_health/release — проблема пула не должна ронять сбор).""" + + class _BoomProvider(_FakeProxyProvider): + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + raise RuntimeError("pool db down") + + lease = ProxyLease(id=3, url="http://pool:8080", kind="http") + provider = _BoomProvider(lease) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True) + + bf.report_ban("captcha") # не должно бросить + + +async def test_report_ban_does_not_release_or_rotate_lease() -> None: + """report_ban только сообщает пулу — lease НЕ release'ится и НЕ ротируется здесь + (это забота caller'а: обычно исключение пробрасывается наверх и __aexit__ + релизит lease как обычно).""" + lease = ProxyLease(id=5, url="http://pool:8080", kind="http") + provider = _FakeProxyProvider(lease) + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True) + + bf.report_ban("content-block suspected") + + assert provider.released == [] + assert bf._lease is not None + assert bf._lease.id == 5 diff --git a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py index 8352d77d..91442669 100644 --- a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py @@ -1,8 +1,15 @@ -"""Тесты проброса browser-пула в SERP-скраперы yandex/cian (#2160). +"""Тесты проброса browser-пула в SERP-скраперы avito/yandex/cian (#2160, #2616). -Зеркало P4 (#2164) для avito: там пул раздавался только в avito-путях, а yandex/cian -SERP создавали BrowserFetcher сами в __aenter__ без пула → весь трафик шёл через -env-прокси браузера. Здесь проверяем, что YandexRealtyScraper / CianScraper прокидывают +Изначально (#2164 P4) пул раздавался только в avito-путях, а yandex/cian SERP создавали +BrowserFetcher сами в __aenter__ без пула → их трафик шёл через env-прокси браузера — +покрыто ниже (секции Yandex/Cian). #2616 закрывает СИММЕТРИЧНЫЙ пробел: сам Avito SERP +(`AvitoScraper.__init__`/`__aenter__`) не принимал `proxy_provider` вообще (в отличие от +CianScraper/YandexRealtyScraper) — `build_browser_fetcher(config, "avito")` вызывался +без proxy_provider, поэтому браузерный путь Avito ходил ТОЛЬКО через мёртвый +`BROWSER_PROXY_AVITO` (issue #2613/#2616, root cause 52/53 banned-прогонов за 14 дней). +Секция Avito ниже проверяет тот же инвариант, что и Yandex/Cian. + +Здесь проверяем, что AvitoScraper / YandexRealtyScraper / CianScraper прокидывают proxy_provider + use_pool=config.use_proxy_pool_browser в BrowserFetcher. Инвариант ship-dark: @@ -33,6 +40,7 @@ from typing import Any import pytest from scraper_kit.providers import _base as kit_base +from scraper_kit.providers.avito import serp as avito_serp from scraper_kit.providers.cian import serp as cian_serp from scraper_kit.providers.yandex import serp as yandex_serp @@ -64,6 +72,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]: endpoint: str, proxy_provider: object | None = None, use_pool: bool = False, + environment: str = "dev", ) -> None: calls.append( { @@ -71,6 +80,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]: "endpoint": endpoint, "proxy_provider": proxy_provider, "use_pool": use_pool, + "environment": environment, } ) @@ -177,3 +187,94 @@ async def test_cian_serp_no_pool_by_default(monkeypatch: pytest.MonkeyPatch) -> assert len(calls) == 1 assert calls[0]["proxy_provider"] is None assert calls[0]["use_pool"] is False + + +# ── Avito SERP (#2616) ────────────────────────────────────────────────────── + + +class _FakeCffiSession: + """No-op curl_cffi AsyncSession stand-in — AvitoScraper.__aenter__ builds a + curl_cffi fallback session даже в browser-mode (#1805, через + providers._base.build_document_session → _base.AsyncSession — НЕ + avito_serp.AsyncSession, тот используется только в _rotate_ip), не относится + к проверяемому здесь инварианту (BrowserFetcher kwargs). Патчим + kit_base.AsyncSession, чтобы не конструировать реальную curl_cffi сессию.""" + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def close(self) -> None: + return None + + +def _avito_config(*, use_pool: bool) -> types.SimpleNamespace: + return types.SimpleNamespace( + browser_http_endpoint="http://browser:3000", + use_proxy_pool_browser=use_pool, + scraper_fetch_mode="browser", + scraper_proxy_url=None, + ) + + +@pytest.mark.asyncio +async def test_avito_serp_wires_pool_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None: + """proxy_provider + флаг on → BrowserFetcher получает провайдер + use_pool=True. + + #2616 root-cause: до этого фикса AvitoScraper.__init__ не принимал + proxy_provider вовсе (AttributeError/TypeError на попытке передать) — + build_browser_fetcher(config, "avito") в __aenter__ вызывался БЕЗ + proxy_provider, поэтому браузерный путь Avito шёл только через мёртвый + BROWSER_PROXY_AVITO env-прокси. + """ + rec_fetcher, calls = _make_recorder() + monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher) + monkeypatch.setattr(kit_base, "AsyncSession", _FakeCffiSession) + provider = _FakeProxyProvider() + + scraper = avito_serp.AvitoScraper(_avito_config(use_pool=True), proxy_provider=provider) + async with scraper: + pass + + assert len(calls) == 1 + assert calls[0]["source"] == "avito" + assert calls[0]["proxy_provider"] is provider + assert calls[0]["use_pool"] is True + + +@pytest.mark.asyncio +async def test_avito_serp_no_pool_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Без provider/флага → BrowserFetcher(proxy_provider=None, use_pool=False) — ship-dark. + + AvitoScraper(config) без явного proxy_provider= (позиционка совместимость со + старыми вызывающими) — поведение byte-в-byte как до #2616. + """ + rec_fetcher, calls = _make_recorder() + monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher) + monkeypatch.setattr(kit_base, "AsyncSession", _FakeCffiSession) + + scraper = avito_serp.AvitoScraper(_avito_config(use_pool=False)) + async with scraper: + pass + + assert len(calls) == 1 + assert calls[0]["source"] == "avito" + assert calls[0]["proxy_provider"] is None + assert calls[0]["use_pool"] is False + + +@pytest.mark.asyncio +async def test_avito_serp_provider_without_flag_stays_dark( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """provider задан, но флаг off → use_pool=False (BrowserFetcher пул не трогает).""" + rec_fetcher, calls = _make_recorder() + monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher) + monkeypatch.setattr(kit_base, "AsyncSession", _FakeCffiSession) + provider = _FakeProxyProvider() + + scraper = avito_serp.AvitoScraper(_avito_config(use_pool=False), proxy_provider=provider) + async with scraper: + pass + + assert calls[0]["proxy_provider"] is provider + assert calls[0]["use_pool"] is False diff --git a/tradein-mvp/backend/tests/test_listing_source_snapshot.py b/tradein-mvp/backend/tests/test_listing_source_snapshot.py index 1f98a28c..e063bfb6 100644 --- a/tradein-mvp/backend/tests/test_listing_source_snapshot.py +++ b/tradein-mvp/backend/tests/test_listing_source_snapshot.py @@ -75,26 +75,40 @@ def test_snapshot_derives_is_active_and_payload_hash() -> None: # ── Event-diff CTE SQL ──────────────────────────────────────────────────────── -def test_event_diff_is_set_based_cte_not_python_loop() -> None: - """Event diff is one set-based INSERT … SELECT over a CTE — never a row-by-row loop.""" +def test_event_diff_is_set_based_lateral_not_python_loop() -> None: + """Event diff is one set-based INSERT … SELECT with a LATERAL join — no Python loop. + + #2607: prior used to be a `DISTINCT ON (listing_source_id) ... FROM + listing_source_snapshots` CTE joined via plain JOIN — the planner's Nested Loop + (no Materialize, misestimated `today` row count) re-executed the DISTINCT ON over + the whole table once per today-row, hanging for days. Rewritten as `JOIN LATERAL + (... ORDER BY snapshot_date DESC LIMIT 1) ON true` — forces a per-row indexed + point-lookup via idx_lss_source_date instead of a full-table DISTINCT ON. + """ assert "WITH today AS" in _EVENT_DIFF_SQL - assert "prior AS" in _EVENT_DIFF_SQL - assert "DISTINCT ON (listing_source_id)" in _EVENT_DIFF_SQL + assert "JOIN LATERAL" in _EVENT_DIFF_SQL + assert "prior AS" not in _EVENT_DIFF_SQL, "prior CTE removed — replaced by LATERAL (#2607)" + assert "DISTINCT ON" not in _EVENT_DIFF_SQL, "DISTINCT ON over full table removed (#2607)" assert "INSERT INTO listing_source_events" in _EVENT_DIFF_SQL - # Prior = most-recent snapshot strictly before today. - assert "snapshot_date < CURRENT_DATE" in _EVENT_DIFF_SQL + # LATERAL subquery: most-recent snapshot strictly before today, per listing_source_id. + assert "s.listing_source_id = t.listing_source_id" in _EVENT_DIFF_SQL + assert "s.snapshot_date < CURRENT_DATE" in _EVENT_DIFF_SQL assert "snapshot_date = CURRENT_DATE" in _EVENT_DIFF_SQL - assert "ORDER BY listing_source_id, snapshot_date DESC" in _EVENT_DIFF_SQL - # No Python iteration over rows in the writer body (set-based only). + assert "ORDER BY s.snapshot_date DESC" in _EVENT_DIFF_SQL + assert "LIMIT 1" in _EVENT_DIFF_SQL + # No Python iteration over rows in the writer body (set-based only — LATERAL is a + # Postgres execution-plan construct, not a Python loop). body = _WRITER_SRC.split('"""', 2)[-1] assert "for " not in body, "writer must be set-based — no Python row loop" def test_event_diff_emits_price_change_with_diff_percent() -> None: assert "'price_change'" in _EVENT_DIFF_SQL - # diff_percent = (new-old)/old*100. + # diff_percent = (new-old)/old*100. NULLIF на знаменателе (#2674): выражения + # VALUES вычисляются ДО фильтра e.fires, поэтому предикат "p.price_rub <> 0" + # больше не защищает само деление. assert "(t.price_rub - p.price_rub)" in _EVENT_DIFF_SQL - assert "/ p.price_rub * 100" in _EVENT_DIFF_SQL + assert "/ NULLIF(p.price_rub, 0) * 100" in _EVENT_DIFF_SQL # Only when the price actually changed and old is a usable denominator. assert "t.price_rub <> p.price_rub" in _EVENT_DIFF_SQL assert "p.price_rub <> 0" in _EVENT_DIFF_SQL @@ -188,22 +202,30 @@ def test_migration_079_uses_psycopg_safe_sql() -> None: class _FakeResult: - def __init__(self, rowcount: int) -> None: + def __init__(self, rowcount: int, rows: list[tuple[str, int]] | None = None) -> None: self.rowcount = rowcount + self._rows = rows or [] + + def fetchall(self) -> list[tuple[str, int]]: + """Event-diff statement возвращает пары (counter_key, n) — см. #2674.""" + return self._rows class _FakeDB: """Minimal stand-in for a SQLAlchemy Session — records execute() calls, returns rowcounts.""" - def __init__(self, rowcounts: list[int]) -> None: + def __init__( + self, rowcounts: list[int], event_rows: list[tuple[str, int]] | None = None + ) -> None: self._rowcounts = list(rowcounts) + self._event_rows = event_rows or [] self.executed: list[Any] = [] self.committed = False self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) - return _FakeResult(self._rowcounts.pop(0)) + return _FakeResult(self._rowcounts.pop(0), self._event_rows) def commit(self) -> None: self.committed = True @@ -213,7 +235,12 @@ class _FakeDB: def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: - """snapshot_listing_sources maps the two execute() rowcounts to its counters + marks done.""" + """snapshot_listing_sources maps the snapshot rowcount + per-type event rows to counters. + + #2674: event-diff больше не отдаёт один rowcount — внешний SELECT над + data-modifying CTE возвращает пары (counter_key, n) по типам событий, а + counters инициализированы всеми пятью ключами (не сработавший тип = честный 0). + """ marked: dict[str, Any] = {} monkeypatch.setattr( snap_mod.runs_mod, @@ -222,18 +249,72 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: ) monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) - db = _FakeDB(rowcounts=[18355, 42]) # snapshot rowcount, then event rowcount + # rowcounts: SET LOCAL statement_timeout (ignored), snapshot upsert, event-diff. + db = _FakeDB(rowcounts=[0, 18355, 0], event_rows=[("price_change_events", 42)]) out = snap_mod.snapshot_listing_sources(db, run_id=99) # type: ignore[arg-type] - assert out == {"snapshotted": 18355, "price_change_events": 42} + assert out == { + "snapshotted": 18355, + "price_change_events": 42, + "edited_events": 0, + "first_seen_events": 0, + } assert db.committed is True - assert len(db.executed) == 2 - # run_id threaded into the snapshot statement's bind params. - _stmt, params = db.executed[0] + assert len(db.executed) == 3 + # First statement sets the per-transaction wall-clock budget (#2607). + stmt0, _params0 = db.executed[0] + assert "SET LOCAL statement_timeout" in str(stmt0) + # run_id threaded into the snapshot statement's bind params (now executed[1]). + _stmt, params = db.executed[1] assert params is not None and params["run_id"] == 99 # Run finalised via mark_done with the same counters. assert marked["run_id"] == 99 - assert marked["counters"] == {"snapshotted": 18355, "price_change_events": 42} + assert marked["counters"] == out + + +# ── budget_sec / statement_timeout (#2607) ───────────────────────────────────── + + +def test_clamp_budget_sec_defaults_and_bounds() -> None: + assert snap_mod._clamp_budget_sec(snap_mod.DEFAULT_BUDGET_SEC) == snap_mod.DEFAULT_BUDGET_SEC + # Below floor / garbage / zero (the historical bug: 0 == "no timeout") clamp to the floor. + assert snap_mod._clamp_budget_sec(0) == snap_mod._MIN_BUDGET_SEC + assert snap_mod._clamp_budget_sec(-5) == snap_mod._MIN_BUDGET_SEC + assert snap_mod._clamp_budget_sec(None) == snap_mod.DEFAULT_BUDGET_SEC + assert snap_mod._clamp_budget_sec("garbage") == snap_mod.DEFAULT_BUDGET_SEC + # Above ceiling clamps down — never lets a fat-fingered value re-create "hangs forever". + assert snap_mod._clamp_budget_sec(999_999) == snap_mod._MAX_BUDGET_SEC + # Sane custom value passes through unclamped. + assert snap_mod._clamp_budget_sec(120) == 120.0 + + +def test_snapshot_listing_sources_sets_statement_timeout_from_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """budget_sec from default_params is applied via SET LOCAL statement_timeout (ms).""" + monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) + + db = _FakeDB(rowcounts=[0, 10, 1]) + snap_mod.snapshot_listing_sources(db, run_id=1, params={"budget_sec": 120}) # type: ignore[arg-type] + + stmt0, _params0 = db.executed[0] + assert "SET LOCAL statement_timeout = 120000" in str(stmt0) + + +def test_snapshot_listing_sources_defaults_budget_sec_when_params_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No params / no budget_sec key → DEFAULT_BUDGET_SEC applied (never unlimited/0).""" + monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None) + + db = _FakeDB(rowcounts=[0, 10, 1]) + snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type] + + stmt0, _params0 = db.executed[0] + expected_ms = int(snap_mod.DEFAULT_BUDGET_SEC * 1000) + assert f"SET LOCAL statement_timeout = {expected_ms}" in str(stmt0) def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tradein-mvp/backend/tests/test_listings_city_from_sweep.py b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py new file mode 100644 index 00000000..3b52b0ac --- /dev/null +++ b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py @@ -0,0 +1,338 @@ +"""#2594: listings.city проставляется из контекста развёртки, не парсингом адреса. + +Критичный дефект: скрапер ЗНАЕТ город в момент сбора (city_slug из +scraper_kit.orchestration.pipeline.CITY_LOCATIONS/CITY_ANCHORS), но раньше нигде его +не записывал. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте +("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30" — cian даже явно вырезает +location-часть, providers/cian/serp.py `_format_address` skip_types={"location",...}). +Без города такой адрес при геокодинге считался «город не назван» и коллизировал с +одноимённой ЕКБ-улицей (Ленина/Победы/Тенистая — сотни совпадений в ЕКБ-реестре). + +Fix: отдельная колонка `listings.city`, проставляется из sweep-контекста (НЕ парсингом +address) через `scraper_kit.base.save_listings(..., city=...)` + +`scraper_kit.orchestration.pipeline.resolve_city_name(city_slug)`. Тесты здесь проверяют +write-path (save_listings SQL) и pure resolve_city_name; orchestration-level проверки +(save_listings вызывается с правильным city= из каждого sweep) — в +test_scraper_kit_pipeline_parity.py / test_scraper_kit_pipeline_parity2.py. + +Границы (#2594): бэкфилл уже накопленных строк — НЕ в этой задаче. +""" + +from __future__ import annotations + +import os +import re +from contextlib import contextmanager +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.base import ScrapedLot as KitLot +from scraper_kit.base import save_listings as kit_save_listings + + +@contextmanager +def _nested_ctx() -> Any: + yield MagicMock() + + +def _mock_db_insert_path(listing_id: int = 42) -> MagicMock: + """Session mock для fresh INSERT path (xmax = 0 → inserted).""" + insert_row = MagicMock() + insert_row.id = listing_id + insert_row.inserted = True + + db = MagicMock() + + def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock: + s = str(sql) + res = MagicMock() + if "SELECT card_hash" in s and "WHERE dedup_hash" in s: + res.fetchone.return_value = None + elif "FROM listings_snapshots" in s: + res.fetchone.return_value = None + elif "INSERT INTO listings (" in s: + res.fetchone.return_value = insert_row + else: + res.fetchone.return_value = None + return res + + db.execute.side_effect = _execute + db.begin_nested.side_effect = _nested_ctx + return db + + +def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]: + for call in db.execute.call_args_list: + sql = str(call.args[0]) + if needle in sql: + params = call.args[1] if len(call.args) > 1 else {} + return sql, params + raise AssertionError(f"SQL containing {needle!r} not found") + + +def _kit_matcher() -> MagicMock: + matcher = MagicMock() + matcher.match_or_create_house.return_value = (101, 1.0, "new") + matcher.upsert_listing_source.return_value = None + return matcher + + +def _lot( + source: str = "avito", + source_id: str = "1", + address: str | None = None, + lat: float | None = None, + lon: float | None = None, +) -> KitLot: + return KitLot( + source=source, + source_url=f"https://www.{source}.ru/item/{source_id}", + source_id=source_id, + address=address, + lat=lat, + lon=lon, + price_rub=3_000_000, + ) + + +# ── save_listings(..., city=...) — INSERT path ──────────────────────────────── + + +def test_save_listings_writes_city_into_insert_sql() -> None: + """city="Нижний Тагил" передаётся в SQL params И колонка есть в INSERT-списке.""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Победы, 30") + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Нижний Тагил") + + sql, params = _find_call(db, "INSERT INTO listings (") + assert "city" in sql, "city column must be in INSERT column list" + assert params["city"] == "Нижний Тагил" + # address НЕ тронут — критичное требование #2594 (раздельная колонка, а не + # дописывание города в текст адреса, чтобы не сломать downstream text-парсеры). + assert params["address"] == "ул. Победы, 30" + + +def test_save_listings_city_defaults_to_none_backward_compat() -> None: + """Caller без city= (старые/ad-hoc пути) — колонка остаётся NULL, backward-compatible.""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Малышева, 30") + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66) + + _sql, params = _find_call(db, "INSERT INTO listings (") + assert params["city"] is None + + +def test_save_listings_ekaterinburg_city_written_unchanged_address() -> None: + """ЕКБ-развёртка (city="Екатеринбург") — тот же путь, address не деградирует.""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Малышева, 30") + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Екатеринбург") + + _sql, params = _find_call(db, "INSERT INTO listings (") + assert params["city"] == "Екатеринбург" + assert params["address"] == "ул. Малышева, 30" + + +# ── ON CONFLICT DO UPDATE / reconcile UPDATE — COALESCE не затирает known city ── + + +def test_save_listings_on_conflict_coalesces_city() -> None: + """ON CONFLICT DO UPDATE — city = COALESCE(EXCLUDED.city, listings.city), не blind overwrite.""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Победы, 30") + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Нижний Тагил") + + sql, _params = _find_call(db, "INSERT INTO listings (") + assert "city = COALESCE(EXCLUDED.city, listings.city)" in sql + + +def test_save_listings_reconcile_update_coalesces_city() -> None: + """dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:city, city), не blind overwrite.""" + import psycopg.errors + from sqlalchemy.exc import IntegrityError + + uv_orig = psycopg.errors.UniqueViolation() + integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig) + rec_row = MagicMock() + rec_row.id = 88 + + db = MagicMock() + + def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock: + s = str(sql) + res = MagicMock() + if "SELECT card_hash" in s and "WHERE dedup_hash" in s: + res.fetchone.return_value = None + elif "FROM listings_snapshots" in s: + res.fetchone.return_value = None + elif "INSERT INTO listings (" in s: + raise integrity_err + elif "UPDATE listings" in s and "SET dedup_hash" in s: + res.fetchone.return_value = rec_row + else: + res.fetchone.return_value = None + return res + + db.execute.side_effect = _execute + + @contextmanager + def _nested() -> Any: + try: + yield MagicMock() + except IntegrityError: + raise + + db.begin_nested.side_effect = _nested + + lot = _lot(source="avito", source_id="7960764619", address="ул. Тенистая, 17") + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66, city="Серов") + + sql, params = _find_call(db, "SET dedup_hash") + assert "city = COALESCE(:city, city)" in sql + assert params["city"] == "Серов" + + +# ── Geo-guard: соседний-город-в-развёртке ────────────────────────────────────── +# +# Замер на проде (см. PR): yandex-развёртка city_slug="verkhnyaya_pyshma" +# (city-scoped rgid, ~15.3км anchor'а В.Пышмы от центра ЕКБ — lat/lon/radius_m +# у yandex gate-API игнорируются, см. providers/yandex/serp.py:fetch_around) +# проставляла "Верхняя Пышма" 97% найденного — большинство физически в Екатеринбурге. +# save_listings(..., city_anchor=..., city_radius_km=...) режет city per-lot, если +# у лота ЕСТЬ координаты и они дальше city_radius_km от city_anchor. +_VP_ANCHOR = (56.976, 60.578) # CITY_ANCHORS["verkhnyaya_pyshma"][0][:2] +_VP_RADIUS_KM = 8.0 # get_city_stamp_radius_km("verkhnyaya_pyshma") +_EKB_CENTER = (56.8389, 60.6057) # ~15.34км от _VP_ANCHOR — вне guard-радиуса + + +def test_save_listings_geo_guard_drops_city_for_lot_outside_radius() -> None: + """Лот с координатами ЕКБ в развёртке 'Верхняя Пышма' (guard 8км) — city НЕ + проставляется 'Верхняя Пышма' (дистанция ~15.3км > 8км).""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Победы, 30", lat=_EKB_CENTER[0], lon=_EKB_CENTER[1]) + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings( + db, + [lot], + matcher=_kit_matcher(), + region_code=66, + city="Верхняя Пышма", + city_anchor=_VP_ANCHOR, + city_radius_km=_VP_RADIUS_KM, + ) + + _sql, params = _find_call(db, "INSERT INTO listings (") + assert params["city"] is None, "лот физически в ЕКБ НЕ должен получить 'Верхняя Пышма'" + + +def test_save_listings_geo_guard_keeps_city_for_lot_inside_radius() -> None: + """Лот с координатами самой В.Пышмы (anchor, dist=0) — city проставлен как обычно.""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Кривоусова, 5", lat=_VP_ANCHOR[0], lon=_VP_ANCHOR[1]) + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings( + db, + [lot], + matcher=_kit_matcher(), + region_code=66, + city="Верхняя Пышма", + city_anchor=_VP_ANCHOR, + city_radius_km=_VP_RADIUS_KM, + ) + + _sql, params = _find_call(db, "INSERT INTO listings (") + assert params["city"] == "Верхняя Пышма" + + +def test_save_listings_geo_guard_stamps_city_for_lot_without_coords() -> None: + """Лот БЕЗ координат (avito — большинство, напр. Серов 142/150) — нечем сверить + против anchor'а, поэтому city ВСЁ РАВНО проставляется (решение #2620): провайдер + уже скоупил SERP/API-запрос на этот город (city_slug/rgid/region_id), а без city + колонка теряет главную ценность именно для адресов без города в тексте.""" + db = _mock_db_insert_path() + lot = _lot(address="ул. Ленина, 1", lat=None, lon=None) + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings( + db, + [lot], + matcher=_kit_matcher(), + region_code=66, + city="Верхняя Пышма", + city_anchor=_VP_ANCHOR, + city_radius_km=_VP_RADIUS_KM, + ) + + _sql, params = _find_call(db, "INSERT INTO listings (") + assert params["city"] == "Верхняя Пышма" + + +def test_save_listings_geo_guard_inactive_ekaterinburg_sweep_unaffected() -> None: + """ЕКБ-развёртка (city_anchor/city_radius_km не переданы, как в run_*_city_sweep + при city_slug=None) — guard выключен, city проставляется независимо от координат + лота (даже координаты далёкого Серова не режутся — старое поведение сохранено).""" + db = _mock_db_insert_path() + serov_coords = (59.604, 60.578) + lot = _lot(address="ул. Ленина, 1", lat=serov_coords[0], lon=serov_coords[1]) + + with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None): + kit_save_listings( + db, + [lot], + matcher=_kit_matcher(), + region_code=66, + city="Екатеринбург", + ) + + _sql, params = _find_call(db, "INSERT INTO listings (") + assert params["city"] == "Екатеринбург" + + +# ── Migration 196: listings.city column ──────────────────────────────────────── + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_196 = _SQL_DIR / "196_listings_city.sql" + + +def test_migration_196_exists() -> None: + assert _MIGRATION_196.is_file(), f"missing migration: {_MIGRATION_196}" + + +def test_migration_196_is_transactional() -> None: + sql = _MIGRATION_196.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_196_idempotent_add_column() -> None: + sql = _MIGRATION_196.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS city" in sql + + +def test_migration_196_no_psycopg_cast_trap() -> None: + """psycopg v3: никаких :param::type (не применимо тут — чистый DDL — но проверяем + на будущее, если файл когда-нибудь обрастёт bind-параметрами).""" + sql = _MIGRATION_196.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_migration_196_non_destructive() -> None: + sql = _MIGRATION_196.read_text("utf-8") + assert "DROP" not in sql.upper() + assert "DELETE" not in sql.upper() + assert "TRUNCATE" not in sql.upper() diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index 622e2732..f0a0c371 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -276,64 +276,21 @@ def test_match_house_tier0_cadastr(): address="ул Ленина 5", lat=56.8, lon=60.5, - cadastral_number="66:41:0000000:999", + # Кадастр ЗДАНИЯ — единственный, что принимает ключ дома (#2674). + building_cadastral_number="66:41:0000000:999", ) assert house_id == 42 assert conf == 1.0 assert method == "cadastr_exact" -def test_match_house_tier05_fias_exact(): - """Tier 0.5: house_fias_id match → fias_exact (conf 0.95) before source/fp/geo. - - No cadastral_number supplied → Tier 0 skipped; the fias SELECT is the first - lookup after the advisory lock. On hit, _upsert_house_source + _insert_alias - fire (address carries a house number so the alias is registered). - """ - from app.services.matching.houses import match_or_create_house - - db = _make_db( - [ - None, # pg_advisory_xact_lock - {"id": 55}, # fias_exact hit (Tier 0.5) - None, # _upsert_house_source - None, # _insert_alias - ] - ) - house_id, conf, method = match_or_create_house( - db, - "avito", - "ext-fias-1", - address="ул Ленина 5", - lat=56.8, - lon=60.5, - house_fias_id="0a1b2c3d-0000-4000-8000-000000000001", - ) - assert house_id == 55 - assert conf == 0.95 - assert method == "fias_exact" - - -def test_match_house_fias_skipped_when_absent(): - """No house_fias_id → Tier 0.5 does not run; first lookup is house_sources.""" - from app.services.matching.houses import match_or_create_house - - db = _make_db( - [ - None, # pg_advisory_xact_lock - {"house_id": 8}, # house_sources hit (fias tier skipped, no fias SELECT) - ] - ) - house_id, _conf, method = match_or_create_house( - db, - "avito", - "ext-nofias", - address="пр Мира 10", - lat=56.8, - lon=60.5, - ) - assert house_id == 8 - assert method == "source_exact" +# Тесты Tier 0.5 fias_exact для match_or_create_house удалены вместе с самим тиром (#2674): +# передать туда `house_fias_id` не мог ни один вызывающий (ни Protocol HouseMatcher, ни +# RealMatcherAdapter такого параметра не имели), поэтому за всю историю тир не сработал ни разу — +# 0 из 49 502 строк house_sources. Тесты при этом были зелёными: они звали функцию напрямую и +# передавали параметр сами, минуя границу вызова. Ровно этот класс ошибок теперь сторожит +# tests/test_matching_tier_reachability_2674.py (сверка сигнатуры с границей вызова). +# Живой fias-тир остался в match_house_readonly и покрыт тестами estimate-пути. def test_match_house_tier1_source_exact(): @@ -649,7 +606,9 @@ def test_numberless_address_with_cadastral_creates_house(): "cian", "ext-cad", address="екатеринбург улица мамина сибиряка", - cadastral_number="66:41:0000000:12345", + # Именно кадастр ЗДАНИЯ снимает P1-страж: он идентифицирует дом. Кадастр КВАРТИРЫ этого + # права лишён (#2674) — иначе номер квартиры открывал бы создание мега-бакетов. + building_cadastral_number="66:41:0000000:12345", ) assert (house_id, conf, method) == (321, 1.0, "new") assert any( diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py new file mode 100644 index 00000000..80bca572 --- /dev/null +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -0,0 +1,109 @@ +"""Достижимость тиров матчинга домов (#2674). + +Эпик #2674 — «код написан, отревьюен, смержен и ни разу не сработал». Два верхних тира +`match_or_create_house` были именно такими: за всю историю 0 из 49 502 строк house_sources +имеют matched_method 'cadastr_exact' или 'fias_exact'. + +Причина у fias-тира структурная: параметр `house_fias_id` существовал только в сигнатуре +самой функции — ни Protocol `scraper_kit.contracts.HouseMatcher`, ни продуктовый адаптер +`RealMatcherAdapter`, ни прямые вызывающие его не имели, т.е. передать значение было НЕКОМУ. +Обычный тест этого не ловит: он вызывает функцию напрямую и передаёт параметр сам. + +Ловит — сверка сигнатуры с границей вызова. Здесь она и живёт: каждый keyword-параметр +матчера обязан быть выразим у вызывающего, иначе ветка недостижима в проде. +""" + +from __future__ import annotations + +import inspect +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.contracts import HouseMatcher + +from app.services.matching.houses import match_house_readonly, match_or_create_house +from app.services.scraper_adapters import RealMatcherAdapter + + +def _params(func: object) -> set[str]: + return set(inspect.signature(func).parameters) - {"self", "db"} + + +def test_create_path_matcher_params_are_all_reachable_from_the_boundary() -> None: + """Каждый параметр `match_or_create_house` должен быть передаваем через границу. + + Границы две, и обе обязаны выражать полный набор: + * Protocol `HouseMatcher` — контракт, который видит scraper-kit; + * `RealMatcherAdapter` — единственная боевая реализация этого контракта. + + Параметр, которого нет ни там ни там, — недостижимая ветка (случай `house_fias_id`, + #2674). RED до фикса: {'house_fias_id'}. + """ + impl = _params(match_or_create_house) + unreachable_via_protocol = impl - _params(HouseMatcher.match_or_create_house) + unreachable_via_adapter = impl - _params(RealMatcherAdapter.match_or_create_house) + + assert not unreachable_via_protocol, ( + f"параметры матчера, недостижимые через Protocol HouseMatcher: " + f"{sorted(unreachable_via_protocol)} — ветка не сработает в проде" + ) + assert not unreachable_via_adapter, ( + f"параметры матчера, недостижимые через RealMatcherAdapter: " + f"{sorted(unreachable_via_adapter)} — ветка не сработает в проде" + ) + + +def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None: + """Fias-тир удалён там, где источника ФИАС нет, и сохранён там, где он есть. + + `match_house_readonly` зовёт estimator.resolve_target_house и передаёт ФИАС из + payload.target_fias_id / DaData — источник реальный, тир оставляем. + """ + assert "house_fias_id" not in _params(match_or_create_house) + assert "house_fias_id" in _params(match_house_readonly) + + # Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии: + # комментарий-надгробие про удалённый тир остаться должен, ветка — нет. + assert '"fias_exact"' not in inspect.getsource( + match_or_create_house + ), "мёртвая fias-ветка вернулась в путь создания домов" + assert '"fias_exact"' in inspect.getsource(match_house_readonly) + + +def test_readonly_fias_tier_is_actually_fed_by_its_caller() -> None: + """Единственный живой ФИАС-тир должен ПОЛУЧАТЬ аргумент, а не только объявлять параметр. + + Проверки выше — структурные: они видят имя в сигнатуре. Этого мало. Уберут аргумент на + настоящей границе вызова (`estimator.estimate_quality` → `match_house_readonly`) — сигнатура + останется прежней, оба теста выше зелёные, а тир снова мёртв, и мы снова здесь. + + Доказательство, что структурной проверки недостаточно, лежит рядом: `building_cadastral_number` + её проходит и при этом сработал 0 раз из 49 502. + """ + from app.services.estimator import estimate_quality + + src = inspect.getsource(estimate_quality) + assert "match_house_readonly" in src, "estimate_quality больше не зовёт read-only матчер" + assert "house_fias_id=" in src, ( + "estimate_quality перестал передавать house_fias_id — единственный живой fias-тир " + "снова недостижим (это и есть класс ошибки #2674)" + ) + + +def test_house_key_never_accepts_flat_cadastre() -> None: + """Ключ ДОМА строится только на кадастре здания — не на кадастре квартиры (#2674). + + Ловушка «оставленного рабочего приёмника»: у квартиры свой кадастр, поэтому Tier 0 по нему + не сматчит НИКОГДА, упадёт в New-house INSERT и проштампует номер квартиры в + houses.cadastral_number; плюс `cad` снимает P1-страж «безномерный адрес без кадастра не + создаём». Две квартиры одного дома → два дома. RED до фикса: параметр был в сигнатуре, а + `cad = building_cadastral_number or cadastral_number`. + """ + assert "cadastral_number" not in _params(match_or_create_house) + assert "building_cadastral_number" in _params(match_or_create_house) + + src = inspect.getsource(match_or_create_house) + assert ( + "cad = building_cadastral_number\n" in src + ), "в ключ дома вернулся фолбэк на кадастр квартиры" diff --git a/tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py b/tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py new file mode 100644 index 00000000..08e51361 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_197_backfill_listings_city_from_url.py @@ -0,0 +1,158 @@ +"""Static guards for migration 197 (issue #2594 шаг 3 — бэкфилл listings.city +из слага города в Avito source_url для накопленных объявлений). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность, скоуп (только Avito, только city IS NULL, только 6 наших +городов) и НЕдеструктивность к самим listings-строкам по построению. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_197 = _SQL_DIR / "197_backfill_listings_city_from_url.sql" + + +def _sql() -> str: + return _MIGRATION_197.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_197_exists() -> None: + assert _MIGRATION_197.exists(), f"missing migration: {_MIGRATION_197}" + + +def test_migration_197_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_197_only_avito_city_null() -> None: + """WHERE ограничен source='avito' AND city IS NULL — не перетирает то, что + уже проставил скрапер (196), не трогает Cian/Domclick/Yandex.""" + flat = _flat(_executable_sql()) + assert "where source = 'avito'" in flat + assert "and city is null" in flat + + +def test_migration_197_covers_exactly_six_cities() -> None: + """CASE и WHERE ... IN покрывают ровно наши шесть городов Свердловской + обл. — ни больше (не расползаемся на чужие регионы), ни меньше.""" + flat = _flat(_executable_sql()) + expected_pairs = { + "'ekaterinburg'": "екатеринбург", + "'nizhniy_tagil'": "нижний тагил", + "'kamensk-uralskiy'": "каменск-уральский", + "'pervouralsk'": "первоуральск", + "'verhnyaya_pyshma'": "верхняя пышма", + "'serov'": "серов", + } + for slug, _city_lower in expected_pairs.items(): + assert slug in flat, f"missing avito slug branch: {slug}" + # Ровно 6 веток WHEN в CASE (по числу городов). + assert flat.count(" when ") == len(expected_pairs) + + +def test_migration_197_kamensk_slug_uses_dash_not_underscore() -> None: + """Avito отдаёт 'kamensk-uralskiy' (дефис) — НЕ наш внутренний city_slug + 'kamensk_uralskiy' (подчёркивание, CITY_LOCATIONS ключ в pipeline.py). + Регресс на подчёркивание означало бы 0 подхваченных строк на проде.""" + flat = _flat(_executable_sql()) + assert "'kamensk-uralskiy'" in flat + assert "'kamensk_uralskiy'" not in flat + + +def test_migration_197_pyshma_slug_matches_avito_not_internal_key() -> None: + """Avito слаг — 'verhnyaya_pyshma' (без 'k'), а не наш внутренний ключ + 'verkhnyaya_pyshma' (с 'k', CITY_DISPLAY_NAMES/CITY_LOCATIONS в + pipeline.py). На проде встретился только вариант без 'k' — второй сюда + сознательно не добавлен (см. заголовок миграции).""" + flat = _flat(_executable_sql()) + assert "'verhnyaya_pyshma'" in flat + assert "'verkhnyaya_pyshma'" not in flat + + +def test_migration_197_city_names_match_pipeline_display_names() -> None: + """Человекочитаемые названия городов побайтно совпадают с + CITY_DISPLAY_NAMES / EKATERINBURG_CITY_NAME в scraper_kit.orchestration + .pipeline — иначе один и тот же город расщепится на две разные метки + (старые backfilled-строки vs новые, проставленные скрапером).""" + pipeline_path = ( + Path(__file__).resolve().parents[2] + / "packages" + / "scraper-kit" + / "src" + / "scraper_kit" + / "orchestration" + / "pipeline.py" + ) + pipeline_src = pipeline_path.read_text(encoding="utf-8") + + sql = _sql() + expected_names = [ + "Екатеринбург", + "Нижний Тагил", + "Каменск-Уральский", + "Первоуральск", + "Верхняя Пышма", + "Серов", + ] + for name in expected_names: + assert name in sql, f"missing display name in migration: {name}" + assert name in pipeline_src, ( + f"display name {name!r} in migration 197 не найден в pipeline.py " + "CITY_DISPLAY_NAMES/EKATERINBURG_CITY_NAME — риск расщепления " + "одного города на две метки" + ) + + +def test_migration_197_no_ddl() -> None: + """Только UPDATE данных — колонка listings.city уже существует (196), + никакого ALTER/CREATE/DROP здесь быть не должно.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert flat.count("update listings") == 1 + + +def test_migration_197_no_destructive_ddl() -> None: + """Миграция не должна содержать DROP TABLE / TRUNCATE / DELETE.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + + +def test_migration_197_does_not_touch_other_sources_or_region_code() -> None: + """Явно вне scope (#2601/#2604): cian/yandex/domclick и region_code не + упоминаются в исполняемом SQL этой миграции.""" + flat = _flat(_executable_sql()) + assert "cian" not in flat + assert "yandex" not in flat + assert "domclick" not in flat + assert "region_code" not in flat + + +def test_migration_197_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не + применимо в чистом .sql без bind params, но проверяем на регресс + copy-paste из Python-кода).""" + assert not re.search(r":\w+::", _sql()) diff --git a/tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py b/tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py new file mode 100644 index 00000000..ae35a9eb --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_200_region_code_foreign_cities.py @@ -0,0 +1,192 @@ +"""Static guards for migration 200 (issue #2604 п.2 — убрать ложный +region_code=66 у объявлений Avito из чужих городов). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность, скоуп (только Avito, только чужие города, не наши шесть) и +НЕдеструктивность к самим listings-строкам по построению. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_200 = _SQL_DIR / "200_region_code_foreign_cities.sql" + +_OUR_SIX_SLUGS = ( + "ekaterinburg", + "nizhniy_tagil", + "kamensk-uralskiy", + "pervouralsk", + "verhnyaya_pyshma", + "serov", +) + + +def _sql() -> str: + return _MIGRATION_200.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_200_exists() -> None: + assert _MIGRATION_200.exists(), f"missing migration: {_MIGRATION_200}" + + +def test_migration_200_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_200_only_avito() -> None: + """WHERE ограничен source='avito' — cian/domklik/yandex/n1 не трогаются + (у них region_code=66 в основном верен; 27 подозрительных строк там — + сознательно вне scope этой миграции, ненадёжный сигнал).""" + flat = _flat(_executable_sql()) + assert "where source = 'avito'" in flat + + +def test_migration_200_idempotent_guard_present() -> None: + """`AND region_code IS NOT NULL` — повторный прогон находит 0 строк + (уже NULL после первого прогона), UPDATE становится no-op.""" + flat = _flat(_executable_sql()) + assert "and region_code is not null" in flat + + +def test_migration_200_sets_null_not_a_guessed_region() -> None: + """SET region_code = NULL — честное «неизвестно», не подставной код + другого региона (мы не выводим регион из текста адреса).""" + flat = _flat(_executable_sql()) + assert "set region_code = null" in flat + + +def test_migration_200_excludes_exactly_our_six_cities() -> None: + """WHERE ... NOT IN покрывает ровно наши шесть слагов — не больше (не + расширяем защищённый список произвольно), не меньше (иначе один из наших + городов ложно попадёт под обнуление).""" + flat = _flat(_executable_sql()) + for slug in _OUR_SIX_SLUGS: + assert f"'{slug}'" in flat, f"missing protected avito slug: {slug}" + + +def test_migration_200_kamensk_slug_uses_dash_not_underscore() -> None: + """Avito отдаёт 'kamensk-uralskiy' (дефис) — НЕ наш внутренний city_slug + 'kamensk_uralskiy' (подчёркивание, CITY_LOCATIONS ключ в pipeline.py). + Регресс на подчёркивание означал бы, что реальный Каменск-Уральский + ложно обнуляется этой миграцией.""" + flat = _flat(_executable_sql()) + assert "'kamensk-uralskiy'" in flat + assert "'kamensk_uralskiy'" not in flat + + +def test_migration_200_pyshma_slug_matches_avito_not_internal_key() -> None: + """Avito слаг — 'verhnyaya_pyshma' (без 'k'), а не наш внутренний ключ + 'verkhnyaya_pyshma' (с 'k', CITY_LOCATIONS в pipeline.py).""" + flat = _flat(_executable_sql()) + assert "'verhnyaya_pyshma'" in flat + assert "'verkhnyaya_pyshma'" not in flat + + +def test_migration_200_slugs_match_pipeline_source_of_truth() -> None: + """Шесть защищённых слагов побайтно совпадают с CityLocation(...) + .avito_slug в scraper_kit.orchestration.pipeline (CITY_LOCATIONS + + 'ekaterinburg' EKB-дефолт) — иначе список разойдётся с источником + истины и миграция начнёт либо обнулять свои города, либо пропускать + чужие.""" + pipeline_path = ( + Path(__file__).resolve().parents[2] + / "packages" + / "scraper-kit" + / "src" + / "scraper_kit" + / "orchestration" + / "pipeline.py" + ) + pipeline_src = pipeline_path.read_text(encoding="utf-8") + + sql = _sql() + for slug in _OUR_SIX_SLUGS: + assert slug in sql, f"missing avito slug in migration: {slug}" + # 'ekaterinburg' — EKB-дефолт, в pipeline.py не встречается как + # avito_slug строкой (нет явного CityLocation для ЕКБ, city_slug=None + # -> _avito_slug fallback на city_slug), остальные пять — явные + # CityLocation(...).avito_slug значения в CITY_LOCATIONS. + if slug != "ekaterinburg": + assert slug in pipeline_src, ( + f"avito_slug {slug!r} в миграции 200 не найден в pipeline.py " + "CITY_LOCATIONS — риск расхождения защищённого списка с " + "источником истины" + ) + + +def test_migration_200_no_substring_collision_between_slugs() -> None: + """Ни один из шести слагов не является подстрокой другого — точное + сравнение сегмента пути через NOT IN (...) безопасно, LIKE '%slug%' не + нужен и не используется.""" + for a in _OUR_SIX_SLUGS: + for b in _OUR_SIX_SLUGS: + if a == b: + continue + assert a not in b, f"{a!r} is a substring of {b!r} — collision risk" + + flat = _flat(_executable_sql()) + assert "like '%" not in flat + + +def test_migration_200_extracts_exact_path_segment() -> None: + """Слаг извлекается точным сегментом пути через substring(...) regex + (тот же идиом, что 197), не LIKE-паттерном.""" + flat = _flat(_executable_sql()) + assert "substring(source_url from 'avito" in flat + + +def test_migration_200_no_ddl() -> None: + """Только UPDATE данных — никакого ALTER/CREATE/DROP.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert flat.count("update listings") == 1 + + +def test_migration_200_no_destructive_ddl() -> None: + """Миграция не должна содержать DROP TABLE / TRUNCATE / DELETE — ничего + не удаляется, ничего не деактивируется.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + assert "is_active" not in flat + + +def test_migration_200_does_not_touch_other_sources_or_city() -> None: + """Явно вне scope: cian/domklik/yandex/n1 и listings.city не + упоминаются в исполняемом SQL этой миграции.""" + flat = _flat(_executable_sql()) + assert "cian" not in flat + assert "domklik" not in flat + assert "yandex" not in flat + assert " n1 " not in flat + assert "set city" not in flat + + +def test_migration_200_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не + применимо в чистом .sql без bind params, но проверяем на регресс + copy-paste из Python-кода).""" + assert not re.search(r":\w+::", _sql()) diff --git a/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py new file mode 100644 index 00000000..72500fe3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py @@ -0,0 +1,106 @@ +"""Static guards for migration 201 (issue #2613 — выпилить мёртвые узлы +mobileproxy из scrape_proxies вместе с чужим API-ключом в rotate_url). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность, domain-based scope (НЕ по id — они разъезжаются между +средами) и то, что ASocks-строки миграция не задевает. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_201 = _SQL_DIR / "201_purge_dead_mobileproxy_proxies.sql" + + +def _sql() -> str: + return _MIGRATION_201.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_201_exists() -> None: + assert _MIGRATION_201.exists(), f"missing migration: {_MIGRATION_201}" + + +def test_migration_201_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_201_deletes_by_domain_not_id() -> None: + """Условие удаления — по домену mobileproxy.space в url, НЕ по id (id + разъезжается между средами, тот же класс проблемы решён в 199 через + host:port-matching).""" + flat = _flat(_executable_sql()) + assert "delete from scrape_proxies" in flat + assert "where url like '%mobileproxy.space%'" in flat + assert ( + re.search(r"where\s+id\s*(=|in)", flat) is None + ), "миграция не должна фильтровать по id — id разъезжается между средами" + + +def test_migration_201_is_idempotent_by_construction() -> None: + """DELETE ... WHERE без вспомогательного флага — повторный прогон + находит 0 строк (уже удалены в первом прогоне), сам DELETE идемпотентен + по построению, отдельного guard-условия не требуется.""" + flat = _flat(_executable_sql()) + assert flat.count("delete from") == 1 + assert "delete from scrape_proxies" in flat + + +def test_migration_201_does_not_touch_asocks_rows() -> None: + """ASocks-строки (id 1, 9, 10, 11) адресуются IP-хостами + (212.8.249.134 / 190.2.145.131 / 175.110.115.153 / 109.236.82.42) без + mobileproxy.space в url — WHERE их не задевает. Явно запрещаем regression + в сторону id-based или asocks-упоминающего условия.""" + flat = _flat(_executable_sql()) + assert "asocks" not in flat + for asocks_ip in ( + "212.8.249.134", + "190.2.145.131", + "175.110.115.153", + "109.236.82.42", + ): + assert asocks_ip not in flat + + +def test_migration_201_no_ddl() -> None: + """Только DELETE данных — никакого ALTER/CREATE/DROP TABLE/TRUNCATE.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert "truncate" not in flat + + +def test_migration_201_no_secret_literal_in_file() -> None: + """Файл миграции не должен содержать сам секрет (query-параметр + proxy_key mobileproxy.space) — только описание проблемы текстом.""" + sql = _sql() + assert "proxy_key=" not in sql or "proxy_key=..." in sql or "<секрет>" in sql + # Явный запрет на длинные alnum-токены рядом с 'proxy_key=' (сам секрет). + assert not re.search(r"proxy_key=[A-Za-z0-9_-]{10,}", sql) + + +def test_migration_201_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не + применимо в чистом .sql без bind params, но проверяем на регресс + copy-paste из Python-кода).""" + assert not re.search(r":\w+::", _sql()) diff --git a/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py b/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py new file mode 100644 index 00000000..5fbfa03f --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_204_cian_oblast_sweeps_secondary.py @@ -0,0 +1,136 @@ +"""Static guards for migration 204 (включить сбор вторички Циана по 4 областным +city-sweep'ам — Свердловская обл., см. миграцию 179). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует +живой БД; здесь фиксируем структурные инварианты: транзакционность, отсутствие DDL, +отсутствие psycopg CAST-ловушки, jsonb-мердж (не перезапись), ровно 4 таргетных +source'а — и, главное, ДВА regression-guard'а: + 1. 'cian_city_sweep' (ЕКБ, без суффикса города) НЕ фигурирует — для него текущий + дефолт newbuilding_only=True в коде корректен (вторичку ЕКБ авторитетно собирает + run_cian_full_load; включение дало бы дублирующую нагрузку на источник). + 2. 'cian_city_sweep_verkhnyaya_pyshma' НЕ фигурирует — geo-проверка (ST_DWithin от + центра ЕКБ) показала 23% (5 из 22) загрязнение городской метки cian-строк В.Пышмы + екатеринбургскими объявлениями; listings.city — money-critical (читает + asking_to_sold_ratio.py). Включат отдельной миграцией после починки разметки. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_204 = _SQL_DIR / "204_cian_oblast_sweeps_secondary.sql" + +_OBLAST_SOURCES = ( + "cian_city_sweep_nizhniy_tagil", + "cian_city_sweep_kamensk_uralskiy", + "cian_city_sweep_pervouralsk", + "cian_city_sweep_serov", +) + + +def _sql() -> str: + return _MIGRATION_204.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_204_exists() -> None: + assert _MIGRATION_204.exists(), f"missing migration: {_MIGRATION_204}" + + +def test_migration_204_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_204_no_ddl() -> None: + """Только UPDATE данных (default_params) — никакого ALTER/CREATE/DROP TABLE/TRUNCATE.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert "truncate" not in flat + + +def test_migration_204_updates_default_params_via_jsonb_merge() -> None: + """COALESCE(default_params, '{}'::jsonb) || '{...}'::jsonb — мердж, НЕ перезапись + (соседние ключи city/radius_m/detail_top_n/enrich_houses/pages_per_anchor/ + request_delay_sec должны сохраниться).""" + flat = _flat(_executable_sql()) + assert "update scrape_schedules" in flat + assert "set default_params = coalesce(default_params, '{}'::jsonb)" in flat + assert "|| '{\"newbuilding_only\": false}'::jsonb" in flat + # Regression guard: перезапись без COALESCE/|| стёрла бы соседние ключи. + assert "set default_params = '{" not in flat + + +def test_migration_204_targets_exactly_four_oblast_sources() -> None: + flat = _flat(_executable_sql()) + for source in _OBLAST_SOURCES: + assert f"'{source}'" in flat, f"missing target source: {source}" + # Ровно 4 закавыченных source-литерала в WHERE ... IN (...) — не больше, не меньше. + quoted = re.findall(r"'(cian_city_sweep_[a-z_]+)'", flat) + assert sorted(set(quoted)) == sorted(_OBLAST_SOURCES) + assert len(quoted) == 4 + + +def test_migration_204_does_not_touch_ekaterinburg_schedule() -> None: + """Regression-guard против «включили всем»: 'cian_city_sweep' (ЕКБ, БЕЗ суффикса + города) НЕ должен фигурировать в списке таргетов миграции. Для него текущий + дефолт newbuilding_only=True (код) корректен — вторичку ЕКБ авторитетно собирает + run_cian_full_load; включение дало бы дублирующий сбор той же вторички.""" + sql = _sql() + # Каждое вхождение 'cian_city_sweep' в исполняемом SQL обязано иметь city-суффикс — + # ищем токен 'cian_city_sweep' НЕ followed immediately by "_" внутри кавычек. + for match in re.finditer(r"'cian_city_sweep([a-z_]*)'", sql): + suffix = match.group(1) + assert suffix.startswith("_"), ( + "нашли bare 'cian_city_sweep' (ЕКБ-расписание) среди таргетов миграции — " + "это регресс: ЕКБ-вторичку собирает run_cian_full_load, включать её здесь нельзя" + ) + assert suffix[1:] in { + "nizhniy_tagil", + "kamensk_uralskiy", + "pervouralsk", + "serov", + } + + +def test_migration_204_does_not_touch_verkhnyaya_pyshma() -> None: + """Regression-guard против «Пышму забыли обратно включить»: geo-проверка + (ST_DWithin от центра ЕКБ) показала 23% (5 из 22) cian-строк с меткой + city="Верхняя Пышма" физически лежат в 15 км от центра Екатеринбурга — загрязнённая + городская разметка. listings.city — money-critical (asking_to_sold_ratio.py читает + его для city-скоупа ASKING vs SOLD стороны). Включение вторички умножило бы это + загрязнение (22 → несколько сотен строк). Пышму включат отдельной миграцией + ПОСЛЕ починки городской разметки sweep'а — сейчас её НЕ должно быть в WHERE.""" + flat = _flat(_executable_sql()) + assert "cian_city_sweep_verkhnyaya_pyshma" not in flat + + +def test_migration_204_no_psycopg_cast_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо + в чистом .sql без bind params, но проверяем на регресс copy-paste из Python).""" + assert not re.search(r":\w+::", _sql()) + + +def test_migration_204_idempotent_by_construction() -> None: + """UPDATE ... SET x = merge(x, const) — повторный прогон ставит то же значение, + отдельного guard-условия (IF NOT EXISTS/ON CONFLICT) не требуется.""" + flat = _flat(_executable_sql()) + assert flat.count("update scrape_schedules") == 1 diff --git a/tradein-mvp/backend/tests/test_migration_205_sales_vs_listings_city_filter.py b/tradein-mvp/backend/tests/test_migration_205_sales_vs_listings_city_filter.py new file mode 100644 index 00000000..e5e27b2f --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_205_sales_vs_listings_city_filter.py @@ -0,0 +1,159 @@ +"""Static guards for migration 205 (city-scope в street_sales_vs_listings(), #2583 H4). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует +живой БД; здесь фиксируем структурные инварианты: транзакционность, идемпотентность +DROP FUNCTION (старая 6-арг сигнатура), наличие НОВОЙ 7-арг сигнатуры с +p_target_city DEFAULT NULL, city-предикаты на ОБЕИХ сторонах JOIN (deals строго, +listings терпимо к NULL — зеркало asking_to_sold_ratio.py #2583 H2), отсутствие +psycopg CAST-ловушки, отсутствие DROP TABLE/TRUNCATE. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_205 = _SQL_DIR / "205_sales_vs_listings_city_filter.sql" + +_OLD_SIGNATURE = "street_sales_vs_listings(text, numeric, integer, integer, numeric, integer)" +_NEW_SIGNATURE_PARAMS = ( + "text", + "numeric", + "integer", + "integer", + "numeric", + "integer", + "text", +) + + +def _sql() -> str: + return _MIGRATION_205.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_205_exists() -> None: + assert _MIGRATION_205.exists(), f"missing migration: {_MIGRATION_205}" + + +def test_migration_205_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_205_no_destructive_ddl() -> None: + """Только DROP FUNCTION (сигнатура меняется) + CREATE OR REPLACE FUNCTION — + никакого DROP/ALTER TABLE, TRUNCATE (таблицы deals/listings не трогаются).""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "alter table" not in flat + assert "truncate" not in flat + + +def test_migration_205_drops_old_signature_before_replace() -> None: + """CREATE OR REPLACE FUNCTION с добавленным параметром создаёт НОВУЮ + перегрузку (Postgres матчит по списку типов аргументов) — старую 6-арг + сигнатуру нужно дропнуть явно, иначе останутся два оверлоада одной функции. + DROP FUNCTION IF EXISTS идемпотентен: на повторном прогоне (функция уже + 7-арг) — no-op, ошибки нет.""" + flat = _flat(_executable_sql()) + assert f"drop function if exists {_OLD_SIGNATURE.lower()}" in flat + + +def test_migration_205_creates_new_signature_with_target_city_default_null() -> None: + """Новый параметр p_target_city — СЕДЬМОЙ, DEFAULT NULL (обратная + совместимость с любым caller'ом на 6 позиционных аргументах).""" + sql = _sql() + assert "CREATE OR REPLACE FUNCTION street_sales_vs_listings(" in sql + assert "p_target_city text DEFAULT NULL" in sql + # Порядок параметров ВНУТРИ сигнатуры (не в header-комментариях, которые + # упоминают p_target_city раньше по тексту файла): p_target_city должен + # идти ПОСЛЕ p_period_months (седьмым, не разрывая позиционную сигнатуру). + sig_start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(") + sig_body = sql[sig_start:] + period_pos = sig_body.index("p_period_months") + city_pos = sig_body.index("p_target_city") + assert period_pos < city_pos + + +def test_migration_205_comment_on_function_matches_new_signature() -> None: + """COMMENT ON FUNCTION должен ссылаться на НОВУЮ (7-арг) сигнатуру — + иначе COMMENT молча создаст comment на несуществующий оверлоад / упадёт.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_NEW_SIGNATURE_PARAMS) + ")" + assert f"comment on function {new_sig.lower()}" in flat + + +def test_migration_205_deals_side_city_predicate_strict_with_null_fallback() -> None: + """deals.city заполнена на 100% (прод-замер) → строгое равенство при + p_target_city заданном; p_target_city IS NULL (город вне словаря, H1) → + фильтр не применяется — тот же fallback, что и /street-deals.""" + flat = _flat(_executable_sql()) + assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat + + +def test_migration_205_listings_side_city_predicate_tolerant_to_null() -> None: + """listings.city заполнена частично (avito ~63%, yandex ~19%, cian ~4.6%, + domklik ~0.6%, n1 ~0%) — NULL считается "своим" (симметрично + asking_to_sold_ratio.py #2583 H2), иначе строгий фильтр выбросил бы + почти все listings кроме avito.""" + flat = _flat(_executable_sql()) + assert ( + "(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat + ) + + +def test_migration_205_no_psycopg_cast_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type) (не применимо + в чистом .sql без bind params здесь, но проверяем на регресс copy-paste).""" + assert not re.search(r":\w+::", _sql()) + + +def test_migration_205_return_table_shape_unchanged() -> None: + """RETURNS TABLE(...) columns остаются теми же, что в 067 — endpoint + (trade_in.py) читает их по имени через .mappings(), любое переименование/ + удаление сломало бы response mapping без явного сигнала.""" + sql = _sql() + for col in ( + "deal_id", + "deal_date", + "deal_price_rub", + "deal_price_per_m2", + "deal_area_m2", + "deal_rooms", + "deal_floor", + "deal_address", + "listing_id", + "listing_source", + "listing_source_url", + "listing_date", + "listing_price_rub", + "listing_price_per_m2", + "listing_area_m2", + "days_listing_to_deal", + "discount_pct", + ): + assert col in sql, f"missing column in RETURNS TABLE: {col}" + + +def test_migration_205_idempotent_by_construction() -> None: + """DROP FUNCTION IF EXISTS (старая сигнатура) + CREATE OR REPLACE (новая) — + оба идемпотентны по конструкции, отдельного guard-условия не требуется.""" + flat = _flat(_executable_sql()) + assert flat.count("drop function if exists") == 1 + assert flat.count("create or replace function street_sales_vs_listings") == 1 diff --git a/tradein-mvp/backend/tests/test_migration_206_scrape_schedules_cut_wasteful_load.py b/tradein-mvp/backend/tests/test_migration_206_scrape_schedules_cut_wasteful_load.py new file mode 100644 index 00000000..7a9127d2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_206_scrape_schedules_cut_wasteful_load.py @@ -0,0 +1,246 @@ +"""Static guards for migration 206 (срезать бесполезную нагрузку на источники — +scrape_schedules.default_params / .enabled, единственный живой общий прокси). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует +живой БД; здесь фиксируем структурные инварианты: транзакционность, отсутствие DDL, +отсутствие psycopg CAST-ловушки, jsonb-мердж (не перезапись — соседние ключи +default_params должны сохраниться), точные target-source списки и, главное, +regression-guard'ы на то, что миграция НЕ должна трогать: + 1. cian_city_sweep (ЕКБ, БЕЗ суффикса города) и его newbuilding_only-логику + (недавно правились — явный запрет задачи). + 2. avito_full_load_exhaustive (соседняя джоба с похожим именем — легко зацепить + случайным LIKE/подстрокой). + 3. 5 областных yandex_city_sweep_ — address_attempted=0 там (нечего чинить), + enrich_address:false ставим ТОЛЬКО на 'yandex_city_sweep' (ЕКБ). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_206 = _SQL_DIR / "206_scrape_schedules_cut_wasteful_load.sql" + +_OBLAST_SOURCES = ( + "avito_city_sweep_nizhniy_tagil", + "avito_city_sweep_kamensk_uralskiy", + "avito_city_sweep_pervouralsk", + "avito_city_sweep_verkhnyaya_pyshma", + "avito_city_sweep_serov", + "cian_city_sweep_nizhniy_tagil", + "cian_city_sweep_kamensk_uralskiy", + "cian_city_sweep_pervouralsk", + "cian_city_sweep_verkhnyaya_pyshma", + "cian_city_sweep_serov", + "yandex_city_sweep_nizhniy_tagil", + "yandex_city_sweep_kamensk_uralskiy", + "yandex_city_sweep_pervouralsk", + "yandex_city_sweep_verkhnyaya_pyshma", + "yandex_city_sweep_serov", +) + + +def _sql() -> str: + return _MIGRATION_206.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_206_exists() -> None: + assert _MIGRATION_206.exists(), f"missing migration: {_MIGRATION_206}" + + +def test_migration_206_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + # BEGIN должен предшествовать первому UPDATE, COMMIT — идти последним. + # (сверяем по исполняемому SQL — header-комментарий упоминает "UPDATE + # scrape_schedules.default_params" текстом раньше самого BEGIN;) + executable = _executable_sql() + assert executable.index("BEGIN;") < executable.index("UPDATE scrape_schedules") + assert executable.rindex("COMMIT;") > executable.rindex("UPDATE scrape_schedules") + + +def test_migration_206_no_ddl() -> None: + """Только UPDATE данных (default_params/enabled) — никакого ALTER/CREATE/DROP + TABLE/TRUNCATE. Код скраперов не меняется в этой миграции.""" + flat = _flat(_executable_sql()) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert "truncate" not in flat + assert "drop function" not in flat + assert "create or replace function" not in flat + + +def test_migration_206_no_psycopg_cast_trap() -> None: + assert not re.search(r":\w+::", _sql()) + + +def test_migration_206_uses_jsonb_merge_not_overwrite() -> None: + """Все default_params-обновления идут через COALESCE(default_params, '{}'::jsonb) + || '{...}'::jsonb — мердж, НЕ прямая перезапись (иначе стёрлись бы соседние ключи + вроде concurrency/detail_top_n/price_cap_per_bucket/batch_size/radius_m/city).""" + flat = _flat(_executable_sql()) + merge_count = flat.count("set default_params = coalesce(default_params, '{}'::jsonb) ||") + # 7 UPDATE трогают default_params через merge (cian_full_load, avito_full_load, + # yandex_address_backfill, yandex_city_sweep, house_imv_backfill, + # yandex_newbuilding_sweep, oblast-15) — domclick_detail_backfill трогает + # только enabled (boolean column), не default_params. + assert merge_count == 7 + # Regression guard: нигде нет прямой перезаписи default_params = '{...}'::jsonb + # (без COALESCE/||) — это стёрло бы соседние ключи. + assert "set default_params = '{" not in flat + + +def test_migration_206_cian_full_load_params() -> None: + flat = _flat(_executable_sql()) + assert '\'{"concurrency": 2, "request_delay_sec": 6.0, "interval_days": 3}\'::jsonb' in flat + assert "where source = 'cian_full_load'" in flat + + +def test_migration_206_avito_full_load_params() -> None: + flat = _flat(_executable_sql()) + assert '\'{"request_delay_sec": 7.0, "interval_days": 7}\'::jsonb' in flat + assert "where source = 'avito_full_load'" in flat + + +def test_migration_206_does_not_touch_avito_full_load_exhaustive() -> None: + """Regression-guard: 'avito_full_load' — точное совпадение source в WHERE, + НЕ подстрока 'avito_full_load_exhaustive' (соседняя джоба с похожим именем, + уже на request_delay_sec=7.0/interval_days=7 — трогать её эта миграция не должна, + иначе повторное указание тех же ключей маскирует regression, если имена + когда-нибудь разъедутся). Header-комментарий ССЫЛАЕТСЯ на avito_full_load_exhaustive + как на контекст (её собственные 30-дневные counters), но НЕ в исполняемом SQL.""" + assert "avito_full_load_exhaustive" not in _executable_sql() + + +def test_migration_206_yandex_address_backfill_weekly_not_disabled() -> None: + """4 записи/мес — ненулевой поток, граница задачи запрещает полное выключение: + interval_days:7, БЕЗ enabled=false для этого source.""" + flat = _flat(_executable_sql()) + assert ( + "set default_params = coalesce(default_params, '{}'::jsonb) " + "|| '{\"interval_days\": 7}'::jsonb where source = 'yandex_address_backfill'" in flat + ) + assert "enabled = false where source = 'yandex_address_backfill'" not in flat + + +def test_migration_206_yandex_city_sweep_disables_address_enrich_ekb_only() -> None: + """enrich_address:false — ТОЛЬКО на 'yandex_city_sweep' (ЕКБ, address_attempted=5829/ + address_enriched=0 за 30д). Параметр читается scheduler.py:545 + bool(params.get("enrich_address", True)) — код НЕ меняется.""" + flat = _flat(_executable_sql()) + assert "'{\"enrich_address\": false}'::jsonb" in flat + assert "where source = 'yandex_city_sweep'" in flat + # Ровно один литерал 'yandex_city_sweep' без city-суффикса среди WHERE-таргетов + # (bare source, не 'yandex_city_sweep_nizhniy_tagil' и т.д.). + bare_matches = re.findall(r"where source = 'yandex_city_sweep'", flat) + assert len(bare_matches) == 1 + + +def test_migration_206_does_not_touch_oblast_yandex_address_enrich() -> None: + """Regression-guard: 5 областных yandex_city_sweep_ НЕ фигурируют рядом с + enrich_address — address_attempted=0 там за 30д (нечего чинить по данным).""" + sql = _sql() + enrich_idx = sql.index('"enrich_address": false') + # В statement с enrich_address нет city-суффиксов областных yandex sweep'ов. + stmt_start = sql.rindex("UPDATE scrape_schedules", 0, enrich_idx) + stmt_end = sql.index(";", enrich_idx) + stmt = sql[stmt_start:stmt_end] + for city in ("nizhniy_tagil", "kamensk_uralskiy", "pervouralsk", "verkhnyaya_pyshma", "serov"): + assert f"yandex_city_sweep_{city}" not in stmt + + +def test_migration_206_house_imv_backfill_throttled_not_disabled() -> None: + """87% ошибок, но 44 saved/мес (ненулевой поток) — снижаем частоту (interval_days:3), + НЕ выключаем полностью (граница задачи).""" + flat = _flat(_executable_sql()) + assert ( + "set default_params = coalesce(default_params, '{}'::jsonb) " + "|| '{\"interval_days\": 3}'::jsonb where source = 'house_imv_backfill'" in flat + ) + assert "enabled = false where source = 'house_imv_backfill'" not in flat + + +def test_migration_206_domclick_detail_backfill_fully_disabled() -> None: + """0 обогащений из 491 попытки за 30д (буквально нулевой выход) — единственный + пункт, где enabled=false оправдан границей задачи.""" + flat = _flat(_executable_sql()) + assert "set enabled = false" in flat + assert "where source = 'domclick_detail_backfill'" in flat + + +def test_migration_206_yandex_newbuilding_sweep_weekly() -> None: + flat = _flat(_executable_sql()) + assert ( + "set default_params = coalesce(default_params, '{}'::jsonb) " + "|| '{\"interval_days\": 7}'::jsonb where source = 'yandex_newbuilding_sweep'" in flat + ) + + +def test_migration_206_targets_exactly_fifteen_oblast_sources() -> None: + flat = _flat(_executable_sql()) + for source in _OBLAST_SOURCES: + assert f"'{source}'" in flat, f"missing oblast target: {source}" + quoted = re.findall( + r"'((?:avito|cian|yandex)_city_sweep_[a-z_]+)'", + flat, + ) + assert sorted(set(quoted)) == sorted(_OBLAST_SOURCES) + assert len(quoted) == len(_OBLAST_SOURCES) == 15 + + +def test_migration_206_oblast_update_uses_interval_days_3() -> None: + sql = _sql() + where_idx = sql.index("WHERE source IN (") + stmt_start = sql.rindex("UPDATE scrape_schedules", 0, where_idx) + stmt = sql[stmt_start:where_idx] + flat_stmt = _flat(stmt) + assert "|| '{\"interval_days\": 3}'::jsonb" in flat_stmt + + +def test_migration_206_does_not_touch_ekaterinburg_cian_city_sweep() -> None: + """Regression-guard: 'cian_city_sweep' (ЕКБ, БЕЗ суффикса города) и + newbuilding_only НЕ должны фигурировать в исполняемом SQL — явный запрет задачи, + недавно правились отдельно (footer-комментарий упоминает newbuilding_only как + объяснение "НЕ тронуто" — это ожидаемо и не проверяется здесь).""" + executable = _executable_sql() + assert "newbuilding_only" not in executable + for match in re.finditer(r"'cian_city_sweep([a-z_]*)'", executable): + suffix = match.group(1) + assert suffix.startswith("_"), ( + "нашли bare 'cian_city_sweep' (ЕКБ-расписание) среди таргетов миграции — " + "запрещено границами задачи" + ) + + +def test_migration_206_exactly_eight_update_statements() -> None: + """8 отдельных UPDATE: cian_full_load, avito_full_load, yandex_address_backfill, + yandex_city_sweep(enrich_address), house_imv_backfill, + domclick_detail_backfill(enabled), yandex_newbuilding_sweep, oblast-15 (одним IN).""" + flat = _flat(_executable_sql()) + assert flat.count("update scrape_schedules") == 8 + + +def test_migration_206_idempotent_by_construction() -> None: + """Каждый UPDATE — SET x = merge(x, const) / SET enabled = false (константа) — + повторный прогон ставит те же значения, без отдельного guard-условия.""" + flat = _flat(_executable_sql()) + # Ни одного ON CONFLICT/INSERT — чистые UPDATE, все идемпотентны по конструкции. + assert "insert into" not in flat + assert "on conflict" not in flat diff --git a/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py b/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py new file mode 100644 index 00000000..43e40818 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_211_sales_vs_listings_segment_guard.py @@ -0,0 +1,207 @@ +"""Static guards для миграции 211 (сегментный гард #1186 в street_sales_vs_listings(), #2660). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP), полный DB-прогон требует живой +БД — здесь фиксируем структурные инварианты: + - транзакционность + идемпотентность (CREATE OR REPLACE, без DROP/TRUNCATE); + - гард #1186 стоит именно в window_listings (listings-сторона), а не в window_deals + (у deals нет listing_segment) и не в paired; + - `is_active` НЕ появился — его отсутствие осознанно (снятые объявления и есть + материал пейринга), см. шапку 205/211; + - сигнатура НЕ изменилась относительно 205 — иначе CREATE OR REPLACE создаст ВТОРУЮ + перегрузку вместо замены (грабли #2627), и caller получит старое тело; + - city-предикаты #2583 H4 (обе стороны JOIN) на месте — не потеряны при переносе тела; + - RETURNS TABLE не переименована — endpoint читает колонки по имени. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_205 = _SQL_DIR / "205_sales_vs_listings_city_filter.sql" +_MIGRATION_211 = _SQL_DIR / "211_sales_vs_listings_segment_guard.sql" + +_SIGNATURE_TYPES = ("text", "numeric", "integer", "integer", "numeric", "integer", "text") +_SEGMENT_GUARD = "(l.listing_segment is null or l.listing_segment = 'vtorichka')" + + +def _sql(path: Path = _MIGRATION_211) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path = _MIGRATION_211) -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql(path).splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def _function_body(path: Path = _MIGRATION_211) -> str: + """Тело функции между `AS $$` и `$$;`, без `--`-комментариев, нормализованное. + + Отдельно от _executable_sql(): COMMENT ON FUNCTION — тоже исполняемый SQL, и его + текст описывает фильтры словами (в т.ч. «is_active намеренно не фильтруется»). + Проверки «такого предиката в запросе нет» обязаны смотреть только на тело. + """ + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + body = "\n".join( + line.split("--", 1)[0] for line in sql[start:end].splitlines() if line.split("--", 1)[0] + ) + return _flat(body) + + +def _function_signature(path: Path) -> str: + """Блок `CREATE OR REPLACE FUNCTION ...(...)` до RETURNS TABLE, нормализованный.""" + sql = _sql(path) + start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(") + end = sql.index("RETURNS TABLE", start) + return _flat(sql[start:end]) + + +def test_migration_211_exists() -> None: + assert _MIGRATION_211.exists(), f"missing migration: {_MIGRATION_211}" + + +def test_migration_211_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_211_no_destructive_ddl() -> None: + """Только CREATE OR REPLACE FUNCTION + COMMENT — таблицы не трогаем.""" + flat = _flat(_executable_sql()) + assert "drop table" not in flat + assert "alter table" not in flat + assert "truncate" not in flat + assert "delete from" not in flat + + +def test_migration_211_segment_guard_present_in_window_listings() -> None: + """Гард #1186 — канонический предикат, НЕ `= 'vtorichka'` без NULL-ветки + (NULL = legacy вторичка до м.011; прод: 1 557 таких кандидатов).""" + flat = _flat(_executable_sql()) + assert _SEGMENT_GUARD in flat + # Гард стоит в listings-CTE, а не где-то ниже по тексту (в paired/финальном SELECT). + window_listings_pos = flat.index("window_listings as") + paired_pos = flat.index("paired as") + guard_pos = flat.index(_SEGMENT_GUARD) + assert window_listings_pos < guard_pos < paired_pos + + +def test_migration_211_does_not_add_is_active_filter() -> None: + """Отсутствие is_active — ОСОЗНАННОЕ (#2660): функция намеренно смотрит и снятые + объявления, иначе к сделке нечего подставить (объявление снимают ПОСЛЕ продажи).""" + assert "is_active" not in _function_body() + + +def test_migration_211_does_not_add_freshness_filter() -> None: + """Свежесть здесь тоже не при чём: пейринг привязан к дате СДЕЛКИ (window_days), + а не к «сейчас». Регресс-гард против копипасты предиката из location_index.""" + body = _function_body() + assert "l.scraped_at > now()" not in body + assert "last_seen_at" not in body + + +def test_migration_211_signature_identical_to_205_no_new_overload() -> None: + """#2627: CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создаёт ВТОРУЮ перегрузку, + а не замену — caller продолжит попадать в старое тело. Сигнатура 211 обязана + совпадать с 205 побайтово (после нормализации пробелов).""" + assert _function_signature(_MIGRATION_211) == _function_signature(_MIGRATION_205) + + +def test_migration_211_does_not_drop_current_signature() -> None: + """DROP текущей 7-арг сигнатуры не нужен (замена и так in-place) и вреден — + между DROP и CREATE caller получил бы `function does not exist`.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")" + assert f"drop function if exists {new_sig}" not in flat + assert "drop function" not in flat + + +def test_migration_211_exactly_one_function_definition() -> None: + """Ровно одно CREATE OR REPLACE — иначе последнее молча перетрёт предыдущее.""" + flat = _flat(_executable_sql()) + assert flat.count("create or replace function street_sales_vs_listings") == 1 + + +def test_migration_211_comment_targets_the_7arg_signature() -> None: + """COMMENT ON FUNCTION со списком типов — иначе комментарий сядет на + несуществующий оверлоад / упадёт на ambiguity.""" + flat = _flat(_executable_sql()) + new_sig = "street_sales_vs_listings(" + ", ".join(_SIGNATURE_TYPES) + ")" + assert f"comment on function {new_sig}" in flat + + +def test_migration_211_keeps_city_predicates_from_205() -> None: + """Тело переносится целиком — city-скоуп #2583 H4 (обе стороны JOIN) не потерян.""" + flat = _flat(_executable_sql()) + assert "(p_target_city is null or lower(d.city) = lower(p_target_city))" in flat + assert ( + "(p_target_city is null or l.city is null or lower(l.city) = lower(p_target_city))" in flat + ) + + +def test_migration_211_return_table_shape_unchanged() -> None: + """RETURNS TABLE читается endpoint'ом по имени через .mappings() — любое + переименование/удаление сломало бы response mapping без явного сигнала.""" + sql = _sql() + for col in ( + "deal_id", + "deal_date", + "deal_price_rub", + "deal_price_per_m2", + "deal_area_m2", + "deal_rooms", + "deal_floor", + "deal_address", + "listing_id", + "listing_source", + "listing_source_url", + "listing_date", + "listing_price_rub", + "listing_price_per_m2", + "listing_area_m2", + "days_listing_to_deal", + "discount_pct", + ): + assert col in sql, f"missing column in RETURNS TABLE: {col}" + + +def test_migration_211_no_psycopg_cast_trap() -> None: + """Регресс-гард против copy-paste `:param::type` (в чистом .sql bind-параметров нет, + но файл переносится в Python-тесты/скрипты).""" + assert not re.search(r":\w+::", _sql()) + + +def test_migration_211_body_matches_205_except_the_guard() -> None: + """Тело — байт-в-байт копия 205 плюс ровно один новый предикат. Защищает от + случайной потери логики при ручном переносе большого SQL-блока.""" + + def body(path: Path) -> list[str]: + sql = _sql(path) + start = sql.index("AS $$") + end = sql.index("$$;", start) + # Только исполняемые строки, без комментариев — их мы правим осознанно. + return [ + _flat(line.split("--", 1)[0]) + for line in sql[start:end].splitlines() + if line.split("--", 1)[0].strip() + ] + + old_body = body(_MIGRATION_205) + new_body = body(_MIGRATION_211) + added = [line for line in new_body if line not in old_body] + removed = [line for line in old_body if line not in new_body] + assert added == ["and " + _SEGMENT_GUARD], added + assert removed == [], removed diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py new file mode 100644 index 00000000..a03e5c9f --- /dev/null +++ b/tradein-mvp/backend/tests/test_password.py @@ -0,0 +1,387 @@ +"""Тесты для app/core/password.py — bcrypt hash/verify (#2550).""" + +from __future__ import annotations + +import asyncio +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +# С #2665 password.py читает настройки (размер пула проверок) — значит тянет +# `Settings()`, которому нужен DATABASE_URL. В CI он в env (ci-tradein.yml), +# локально подставляем заглушку, как это делает tests/test_auth_api.py. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.core import password as password_mod +from app.core.config import settings +from app.core.password import ( + PasswordVerifyOverloadedError, + hash_password, + verify_password, + verify_password_bounded, +) + + +def test_roundtrip() -> None: + """hash_password → verify_password с тем же паролем возвращает True.""" + hashed = hash_password("correct horse battery staple") + assert verify_password("correct horse battery staple", hashed) is True + + +def test_wrong_password_returns_false() -> None: + """Неверный пароль против валидного хеша → False.""" + hashed = hash_password("correct horse battery staple") + assert verify_password("wrong password", hashed) is False + + +def test_hash_too_long_raises_value_error() -> None: + """Пароль >72 байт в UTF-8 → ValueError в hash_password (нет silent truncation).""" + long_password = "a" * 73 + with pytest.raises(ValueError): + hash_password(long_password) + + +def test_hash_exactly_72_bytes_ok() -> None: + """Ровно 72 байта — граничное значение, ещё допустимо.""" + password = "a" * 72 + hashed = hash_password(password) + assert verify_password(password, hashed) is True + + +def test_hash_too_long_multibyte_raises_value_error() -> None: + """40 кириллических символов = 80 байт UTF-8 (2 байта/символ) → ValueError. + + Проверяет, что лимит считается в байтах, а не в символах — иначе 40-символьный + кириллический пароль (< 72 символов, но 80 байт) прошёл бы мимо guard'а. + """ + long_cyrillic_password = "а" * 40 + assert len(long_cyrillic_password.encode("utf-8")) == 80 + with pytest.raises(ValueError): + hash_password(long_cyrillic_password) + + +def test_verify_too_long_returns_false_not_raise() -> None: + """verify_password на >72-байтовом пароле возвращает False, НЕ raise.""" + hashed = hash_password("some valid password") + long_password = "a" * 73 + assert verify_password(long_password, hashed) is False + + +def test_hash_empty_raises_value_error() -> None: + """Пустой пароль → ValueError в hash_password.""" + with pytest.raises(ValueError): + hash_password("") + + +def test_verify_empty_returns_false() -> None: + """Пустой пароль в verify_password → False (не raise).""" + hashed = hash_password("some valid password") + assert verify_password("", hashed) is False + + +def test_hash_is_unique_due_to_salt() -> None: + """Два хеша одного пароля различаются (уникальная соль на каждый вызов).""" + password = "correct horse battery staple" + hash1 = hash_password(password) + hash2 = hash_password(password) + assert hash1 != hash2 + assert verify_password(password, hash1) is True + assert verify_password(password, hash2) is True + + +def test_verify_malformed_hash_returns_false() -> None: + """Некорректный (не-bcrypt) хеш в verify_password → False, не raise.""" + assert verify_password("some password", "not-a-bcrypt-hash") is False + + +# --------------------------------------------------------------------------- +# #2665 — verify_password_bounded: вне событийного цикла + потолок темпа +# --------------------------------------------------------------------------- + + +def test_verify_ceiling_defaults_stay_within_the_db_pool() -> None: + """Дефолты — часть защиты, а не тюнинг. Стережём их здесь. + + Тест про темп (test_auth_api.py) вычисляет ожидаемый потолок из той же + настройки, которую охраняет, поэтому подъём дефолта он не заметит. А + наступит ослабление именно через настройку: не правкой кода и не ревью, а + строчкой `LOGIN_PASSWORD_VERIFY_WORKERS=32` в `.env.runtime` под предлогом + «входы тормозят». Пусть тогда краснеет хотя бы этот тест. + """ + from app.core.db import engine + + # max_inflight ждущих ДЕРЖАТ по соединению к БД (сессия реестра открыта + # после SELECT в get_user_by_username) — очередь обязана быть уже пула. + assert ( + settings.login_password_verify_max_inflight < engine.pool.size() + engine.pool._max_overflow + ) + assert settings.login_password_verify_workers == 1, ( + "потолок перебора = workers/282мс. Подъём — осознанное решение " + "«во сколько раз ускоряем перебор», а не рефакторинг: правь вместе с тестом" + ) + # ЛИТЕРАЛЫ, а не арифметика от настройки. Доля на ключ (#2714) считается как + # max_inflight // 2, и сторож вида `cap == max_inflight // 2` был бы + # тавтологией: подъём max_inflight до 64 он бы проспал, а вместе с ним — + # возврат к «один адрес занимает всё» (доля 32 при очереди в 4 живых слота + # ничего не делит). Поэтому здесь зафиксированы ОБА числа. + assert settings.login_password_verify_max_inflight == 4, ( + "очередь 4 выбрана под QueuePool 5+10 и худшее ожидание 4/1×282мс ≈ 1.1с; " + "меняешь — пересчитывай и долю на ключ ниже" + ) + assert password_mod._per_key_slot_cap() == 2, ( + "один адрес держит не больше 2 слотов из 4: половина ёмкости обязана " + "оставаться тем, кто приходит впервые (#2714)" + ) + + +async def test_bounded_gives_same_answer_as_sync() -> None: + """Обёртка не меняет вердикт — она меняет только ГДЕ он считается.""" + hashed = hash_password("correct horse battery staple") + key = "203.0.113.1" + assert await verify_password_bounded("correct horse battery staple", hashed, key=key) is True + assert await verify_password_bounded("wrong password", hashed, key=key) is False + + +async def test_bounded_runs_off_the_event_loop_thread(monkeypatch: pytest.MonkeyPatch) -> None: + """bcrypt считается В ДРУГОМ ПОТОКЕ, а не в потоке событийного цикла. + + Замер на проде: сверка = 282 мс, и ровно столько цикл не обслуживал никого. + Проверка идентичности потока — самая прямая формулировка «цикл свободен»; + таймингом её подменять нельзя, тайминг в CI флейкует. + """ + loop_thread = threading.get_ident() + seen: list[int] = [] + + def _spy(plain: str, hashed: str) -> bool: + seen.append(threading.get_ident()) + return True + + monkeypatch.setattr(password_mod, "verify_password", _spy) + assert await verify_password_bounded("x", "y", key="203.0.113.1") is True + assert seen and seen[0] != loop_thread + + +async def test_bounded_rejects_surplus_instead_of_queueing(monkeypatch: pytest.MonkeyPatch) -> None: + """Сверх лимита — немедленный отказ, а не ожидание в очереди. + + Ожидание выглядело бы безобиднее, но каждый ждущий запрос держит соединение + к БД (сессия реестра открыта после SELECT), а в QueuePool их 5+10: + неограниченная очередь выбрала бы пул и положила API — тем же концом, каким + его клала блокировка цикла. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 2) + + def _slow(plain: str, hashed: str) -> bool: + time.sleep(0.2) + return False + + monkeypatch.setattr(password_mod, "verify_password", _slow) + + # У каждого запроса СВОЙ ключ: тест про ОБЩИЙ потолок, и отказывать здесь + # обязан именно он. С одним ключом на всех первым сработал бы лимит доли + # (#2714) — числа сошлись бы по другой причине, а поломка общего потолка + # осталась бы незамеченной. + results = await asyncio.gather( + *(verify_password_bounded("x", "y", key=f"203.0.113.{i}") for i in range(6)), + return_exceptions=True, + ) + rejected = [r for r in results if isinstance(r, PasswordVerifyOverloadedError)] + admitted = [r for r in results if r is False] + assert len(admitted) == 2, results + assert len(rejected) == 4, results + + # Слоты возвращаются: после отработки очереди вход снова доступен. + assert await verify_password_bounded("x", "y", key="203.0.113.9") is False + + +async def test_bounded_slot_freed_by_the_work_not_by_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Отмена запроса не возвращает слот раньше времени. + + Отмена снимает работу, которая ещё НЕ началась, — с ней проблем нет. Но уже + начатую сверку она не забирает: поток занят ею все 282 мс. Освобождай мы + слот по выходу из корутины, на это время он числился бы свободным, и + одновременно работающих сверок стало бы больше, чем разрешено. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1) + started = threading.Event() + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + started.set() + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + task = asyncio.create_task(verify_password_bounded("x", "y", key="203.0.113.1")) + await asyncio.to_thread(started.wait, 5) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Работа всё ещё занимает поток — слот занят, следующий получает отказ. + # Ключ ДРУГОЙ: отказ обязан прийти от общего потолка (max_inflight=1), а не + # от доли на ключ — иначе тест проверял бы не тот механизм. + with pytest.raises(PasswordVerifyOverloadedError): + await verify_password_bounded("x", "y", key="203.0.113.2") + + finish.set() + for _ in range(100): # дать колбэку доехать до цикла + await asyncio.sleep(0.01) + if settings.login_password_verify_max_inflight > password_mod._verify_inflight: + break + # Отменённая работа вернула И общий слот, И слот своего ключа: тот же адрес + # снова обслуживается (утечка по ключу при пуле в 1 поток была бы вечным + # отказом именно этому адресу и больше ничем себя не проявила). + assert await verify_password_bounded("x", "y", key="203.0.113.1") is False + + +async def test_bounded_frees_slot_when_verify_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Исключение внутри сверки тоже возвращает слот — оба счётчика. + + `verify_password` глотает ValueError/TypeError сама, так что сюда доезжает + только неожиданное (падение библиотеки, MemoryError). Пул из ОДНОГО потока + не прощает: один невозвращённый слот — вечный 429 всем на входе, и внешне + это выглядит не как ошибка bcrypt, а как «вход сломался неизвестно почему». + """ + + def _boom(plain: str, hashed: str) -> bool: + raise MemoryError("bcrypt died") + + monkeypatch.setattr(password_mod, "verify_password", _boom) + with pytest.raises(MemoryError): + await verify_password_bounded("x", "y", key="10.0.0.3") + + monkeypatch.setattr(password_mod, "verify_password", lambda plain, hashed: False) + assert await verify_password_bounded("x", "y", key="10.0.0.3") is False + assert password_mod._verify_inflight == 0 + assert not password_mod._verify_inflight_by_key + + +async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.MonkeyPatch) -> None: + """Один ключ занимает не больше своей доли — остальным ёмкость остаётся (#2714). + + Меряем именно ЭТО, а не латентность: с общим котлом слотов один источник + выбирал его целиком, и вход с другого адреса получал 429 бессрочно — + потолок темпа исправно работал против легитимных пользователей. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4) + assert password_mod._per_key_slot_cap() == 2 # 4 // 2 — исходные условия теста + + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + async def _wait_inflight(n: int) -> None: + deadline = time.monotonic() + 5 + while password_mod._verify_inflight < n: + assert ( + time.monotonic() < deadline + ), f"слотов занято {password_mod._verify_inflight} < {n}" + await asyncio.sleep(0.005) + + flood = [ + asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.1")) for _ in range(2) + ] + await _wait_inflight(2) + + # Третий с ТОГО ЖЕ адреса — отказ, хотя два слота из четырёх свободны. + # Это ЦЕНА правки, а не побочный эффект: три одновременных входа из одного + # офиса за NAT укладываются в окно одной сверки (282 мс), и третьему + # сотруднику теперь отказывают при наполовину пустом пуле — до правки для + # этого требовалось пятеро. Закрепляем явно, чтобы размен нельзя было + # потерять молча: свои с ЧУЖИХ адресов за это получают 100% вместо 37%. + assert password_mod._verify_inflight == 2 + assert settings.login_password_verify_max_inflight == 4 + with pytest.raises(PasswordVerifyOverloadedError): + await verify_password_bounded("x", "y", key="10.0.0.1") + + # А с другого — пускают. Задачу ставим ДО finish.set() и ждём, пока она + # займёт слот: иначе «пустили» означало бы только «флуд успел закончиться». + legit = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.2")) + await _wait_inflight(3) + + finish.set() + assert await legit is False, "вход с другого адреса обязан пройти во время флуда" + assert [await f for f in flood] == [False, False] + + +def test_per_key_cap_never_rounds_down_to_zero(monkeypatch: pytest.MonkeyPatch) -> None: + """При очереди в 1 слот доля не округляется в ноль. + + `1 // 2 == 0` означало бы «ни одному ключу нельзя ни одного слота» — + молчаливый отказ ВСЕМ на входе, причём тем более незаметный, что настройка + выглядит как безобидное ужесточение. `max(1, …)` — тот же страховочный пол, + что `ge=1` у самой настройки, только от деления. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1) + assert password_mod._per_key_slot_cap() == 1 + + +async def test_bounded_frees_slot_when_pool_refuses_work(monkeypatch: pytest.MonkeyPatch) -> None: + """Пул не принял работу → слот отдан прямо здесь, колбэка ведь не будет. + + Единственный путь, где освобождение НЕ висит на future: `submit` бросает + (пул закрыт на остановке процесса). Утечка тут стоила бы дорого — при пуле + в один поток невозвращённый слот это вечный 429 всем на входе. + """ + dead_pool = ThreadPoolExecutor(max_workers=1) + dead_pool.shutdown() + monkeypatch.setattr(password_mod, "_VERIFY_POOL", dead_pool) + + with pytest.raises(RuntimeError): + await verify_password_bounded("x", "y", key="10.0.0.4") + + assert password_mod._verify_inflight == 0 + assert not password_mod._verify_inflight_by_key + + +async def test_cancelling_queued_work_returns_the_key_slot(monkeypatch: pytest.MonkeyPatch) -> None: + """Отмена ЕЩЁ НЕ НАЧАТОЙ работы возвращает слот — и общий, и ключа. + + Ветка future другая, чем у отмены начатой работы (`cancel()` на очереди + успевает, и работа не исполняется вовсе), поэтому проверяется отдельно: + соседний тест про начатую работу эту не покрывает. Пул из одного потока — + настоящий, так что второй запрос гарантированно ЖДЁТ в очереди. + """ + monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4) + started = threading.Event() + finish = threading.Event() + + def _blocked(plain: str, hashed: str) -> bool: + started.set() + finish.wait(5) + return False + + monkeypatch.setattr(password_mod, "verify_password", _blocked) + + running = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.5")) + await asyncio.to_thread(started.wait, 5) + + queued = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.6")) + deadline = time.monotonic() + 5 + while password_mod._verify_inflight_by_key.get("10.0.0.6") != 1: + assert time.monotonic() < deadline, "второй запрос не занял слот" + await asyncio.sleep(0.005) + + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + + deadline = time.monotonic() + 5 + while "10.0.0.6" in password_mod._verify_inflight_by_key: + assert time.monotonic() < deadline, "слот отменённой очереди не вернулся" + await asyncio.sleep(0.005) + + finish.set() + assert await running is False diff --git a/tradein-mvp/backend/tests/test_payments_receipt.py b/tradein-mvp/backend/tests/test_payments_receipt.py new file mode 100644 index 00000000..564e6326 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_receipt.py @@ -0,0 +1,208 @@ +"""Тесты `app.services.payments.receipt` — сборка Receipt (ФФД 1.05, услуга).""" + +from __future__ import annotations + +import pytest + +from app.services.payments.receipt import ( + ReceiptBuildError, + ReceiptItem, + build_receipt, + receipt_total_kopecks, +) + + +def _item(price_kopecks: int = 99000, quantity: int = 1, tax: str = "none") -> ReceiptItem: + return ReceiptItem( + name="Отчёт об оценке квартиры (электронный)", + price_kopecks=price_kopecks, + quantity=quantity, + tax=tax, # type: ignore[arg-type] + ) + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +def test_build_receipt_happy_path_with_email() -> None: + receipt = build_receipt(items=[_item()], taxation="usn_income", email="buyer@example.com") + + assert receipt["Taxation"] == "usn_income" + assert receipt["Email"] == "buyer@example.com" + assert "Phone" not in receipt + assert len(receipt["Items"]) == 1 + + item = receipt["Items"][0] + assert item["Name"] == "Отчёт об оценке квартиры (электронный)" + assert item["Price"] == 99000 + assert item["Quantity"] == 1 + assert item["Amount"] == 99000 + assert item["Tax"] == "none" + assert item["PaymentMethod"] == "full_payment" + assert item["PaymentObject"] == "service" + + +def test_build_receipt_happy_path_with_phone_only() -> None: + receipt = build_receipt(items=[_item()], taxation="osn", phone="+79990000000") + assert receipt["Phone"] == "+79990000000" + assert "Email" not in receipt + + +def test_build_receipt_accepts_both_email_and_phone() -> None: + receipt = build_receipt( + items=[_item()], taxation="osn", email="buyer@example.com", phone="+79990000000" + ) + assert receipt["Email"] == "buyer@example.com" + assert receipt["Phone"] == "+79990000000" + + +def test_amount_is_price_times_quantity() -> None: + item = _item(price_kopecks=10000, quantity=3) + assert item.amount_kopecks == 30000 + payload = item.to_payload() + assert payload["Amount"] == 30000 + + +# ── Email/Phone обязательность ────────────────────────────────────────────── + + +def test_build_receipt_requires_email_or_phone() -> None: + with pytest.raises(ReceiptBuildError, match=r"Email.*Phone|Phone.*Email"): + build_receipt(items=[_item()], taxation="osn") + + +def test_build_receipt_rejects_blank_email_and_phone() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt(items=[_item()], taxation="osn", email=" ", phone="") + + +# ── Items[].Name длина ─────────────────────────────────────────────────────── + + +def test_item_name_exactly_128_chars_is_ok() -> None: + item = ReceiptItem(name="A" * 128, price_kopecks=1000) + payload = item.to_payload() + assert payload["Name"] == "A" * 128 + + +def test_item_name_over_128_chars_rejected() -> None: + item = ReceiptItem(name="A" * 129, price_kopecks=1000) + with pytest.raises(ReceiptBuildError, match="128"): + item.to_payload() + + +def test_item_name_empty_rejected() -> None: + item = ReceiptItem(name="", price_kopecks=1000) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +# ── Price / Quantity валидация ────────────────────────────────────────────── + + +def test_item_zero_price_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=0) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +def test_item_negative_price_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=-100) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +def test_item_zero_quantity_rejected() -> None: + item = ReceiptItem(name="X", price_kopecks=100, quantity=0) + with pytest.raises(ReceiptBuildError): + item.to_payload() + + +# ── Taxation / Tax enum ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("taxation", ["osn", "usn_income", "usn_income_outcome", "esn", "patent"]) +def test_all_documented_taxation_values_accepted(taxation: str) -> None: + build_receipt(items=[_item()], taxation=taxation, email="a@test.ru") # type: ignore[arg-type] + + +def test_unknown_taxation_rejected() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt( + items=[_item()], + taxation="usn", + email="a@test.ru", # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "tax_rate", + ["none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"], +) +def test_all_documented_2026_tax_rates_accepted(tax_rate: str) -> None: + """Актуальный список 2026 года — все значения проходят валидацию.""" + item = _item(tax=tax_rate) + payload = item.to_payload() + assert payload["Tax"] == tax_rate + + +@pytest.mark.parametrize("removed_rate", ["vat20", "vat120"]) +def test_removed_vat20_vat120_rates_rejected(removed_rate: str) -> None: + """vat20/vat120 сняты с актуального списка 2026 — не должны проходить.""" + item = _item(tax=removed_rate) + with pytest.raises(ReceiptBuildError, match="vat20/vat120"): + item.to_payload() + + +# ── Items[] границы ────────────────────────────────────────────────────────── + + +def test_build_receipt_rejects_empty_items() -> None: + with pytest.raises(ReceiptBuildError): + build_receipt(items=[], taxation="osn", email="a@test.ru") + + +def test_build_receipt_rejects_more_than_100_items() -> None: + items = [_item() for _ in range(101)] + with pytest.raises(ReceiptBuildError, match="100"): + build_receipt(items=items, taxation="osn", email="a@test.ru") + + +def test_build_receipt_accepts_exactly_100_items() -> None: + items = [_item() for _ in range(100)] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + assert len(receipt["Items"]) == 100 + + +# ── инвариант: сумма Items[].Amount == сумме заказа ───────────────────────── + + +def test_receipt_total_matches_order_amount_invariant() -> None: + """Ключевой инвариант задачи: сумма Items[].Amount == общей сумме заказа.""" + order_amount_kopecks = 148500 + items = [ + ReceiptItem(name="Отчёт об оценке", price_kopecks=99000, quantity=1, tax="none"), + ReceiptItem(name="Персональный оффер", price_kopecks=49500, quantity=1, tax="none"), + ] + receipt = build_receipt(items=items, taxation="usn_income", email="a@test.ru") + + assert receipt_total_kopecks(receipt) == order_amount_kopecks + + +def test_receipt_total_multi_quantity_item() -> None: + items = [ReceiptItem(name="Оценка", price_kopecks=5000, quantity=4, tax="vat22")] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + assert receipt_total_kopecks(receipt) == 20000 + + +def test_receipt_total_kopecks_empty_items_key_returns_zero() -> None: + assert receipt_total_kopecks({"Taxation": "osn"}) == 0 + + +def test_receipt_total_mismatch_detected_by_caller() -> None: + """Демонстрирует, как вызывающая сторона обязана сверять сумму с Init.Amount.""" + items = [ReceiptItem(name="Оценка", price_kopecks=10000, quantity=1, tax="none")] + receipt = build_receipt(items=items, taxation="osn", email="a@test.ru") + + wrong_init_amount_kopecks = 99999 + assert receipt_total_kopecks(receipt) != wrong_init_amount_kopecks diff --git a/tradein-mvp/backend/tests/test_payments_token.py b/tradein-mvp/backend/tests/test_payments_token.py new file mode 100644 index 00000000..7a0ce058 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_token.py @@ -0,0 +1,198 @@ +"""Тесты `app.services.payments.token` — подпись Token + проверка нотификаций. + +Эталонные векторы (`test_sign_matches_official_init_vector`, +`test_sign_matches_official_notification_vector`) сняты ДОСЛОВНО живым +запросом (curl, 2026-08-06) с официального doc-портала Т-Банка: + +- Init: https://developer.tbank.ru/eacq/intro/developer/token + (раздел «Сформировать токен») +- Нотификация: https://developer.tbank.ru/eacq/intro/developer/notification + (раздел «Проверить токен уведомлений») + +Оба payload'а и оба итоговых hex-digest скопированы из HTML doc-портала +(не выдуманы) — см. промежуточные шаги в комментариях у каждого теста. +Если хеш перестанет сходиться — чинить `token.py`, НЕ тест. +""" + +from __future__ import annotations + +import hashlib + +from app.services.payments.token import sign, verify_notification_token + +# ── эталонный вектор №1: Init ──────────────────────────────────────────────── +# Doc-портал, шаг за шагом (см. `token.py` docstring для полного описания): +# 1) [{"TerminalKey": "MerchantTerminalKey"}, {"Amount": 19200}, +# {"OrderId": "00000"}, {"Description": "Подарочная карта на 1000 рублей"}] +# 2) + {"Password": "11111111111111"} +# 3) отсортировано по ключу: Amount, Description, OrderId, Password, TerminalKey +# 4) конкатенация значений: +# "19200Подарочная карта на 1000 рублей0000011111111111111MerchantTerminalKey" +# 5) SHA-256 → "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2" + +_INIT_VECTOR_PAYLOAD = { + "TerminalKey": "MerchantTerminalKey", + "Amount": 19200, + "OrderId": "00000", + "Description": "Подарочная карта на 1000 рублей", +} +_INIT_VECTOR_PASSWORD = "11111111111111" +_INIT_VECTOR_TOKEN = "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2" + + +def test_sign_matches_official_init_vector() -> None: + """Официальный вектор Init из документации Т-Банка.""" + assert sign(_INIT_VECTOR_PAYLOAD, _INIT_VECTOR_PASSWORD) == _INIT_VECTOR_TOKEN + + +# ── эталонный вектор №2: нотификация ───────────────────────────────────────── +# Doc-портал, шаг за шагом: +# 1) [{"TerminalKey": "1234567890DEMO"}, {"OrderId": "000000"}, +# {"Success": true}, {"Status": "AUTHORIZED"}, {"PaymentId": "0000000"}, +# {"ErrorCode": "0"}, {"Amount": 1111}, {"CardId": "000000"}, +# {"Pan": "200000******0000"}, {"ExpDate": "1111"}, {"RebillId": "000000"}] +# 2) + {"Password": "11111111111"} +# 3) отсортировано: Amount, CardId, ErrorCode, ExpDate, OrderId, Pan, +# Password, PaymentId, RebillId, Status, Success, TerminalKey +# 4) конкатенация значений: +# "111100000001111000000200000******0000111111111110000000000000AUTHORIZEDtrue1234567890DEMO" +# 5) SHA-256 → "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8" + +_NOTIFICATION_VECTOR_PAYLOAD = { + "TerminalKey": "1234567890DEMO", + "OrderId": "000000", + "Success": True, + "Status": "AUTHORIZED", + "PaymentId": "0000000", + "ErrorCode": "0", + "Amount": 1111, + "CardId": "000000", + "Pan": "200000******0000", + "ExpDate": "1111", + "RebillId": "000000", +} +_NOTIFICATION_VECTOR_PASSWORD = "11111111111" +_NOTIFICATION_VECTOR_TOKEN = "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8" + + +def test_sign_matches_official_notification_vector() -> None: + """Официальный вектор нотификации (Success/AUTHORIZED) из документации Т-Банка.""" + assert ( + sign(_NOTIFICATION_VECTOR_PAYLOAD, _NOTIFICATION_VECTOR_PASSWORD) + == _NOTIFICATION_VECTOR_TOKEN + ) + + +def test_verify_notification_token_accepts_valid_official_vector() -> None: + """`verify_notification_token` — тот же вектор, но с полем Token внутри payload.""" + payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN} + assert verify_notification_token(payload_with_token, _NOTIFICATION_VECTOR_PASSWORD) is True + + +def test_verify_notification_token_rejects_tampered_field() -> None: + """Изменили Amount после подписи → Token больше не совпадает → False.""" + tampered = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN, "Amount": 9999} + assert verify_notification_token(tampered, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_rejects_wrong_password() -> None: + payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN} + assert verify_notification_token(payload_with_token, "wrong-password") is False + + +def test_verify_notification_token_rejects_missing_token() -> None: + assert verify_notification_token(dict(_NOTIFICATION_VECTOR_PAYLOAD), "any-password") is False + + +def test_verify_notification_token_rejects_empty_token() -> None: + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": ""} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +# ── unit-детали алгоритма ───────────────────────────────────────────────────── + + +def test_bool_true_becomes_lowercase_string() -> None: + """`Success: True` (Python bool) → строка "true" в конкатенации.""" + with_bool = sign({"A": True}, "pw") + with_string = sign({"A": "true"}, "pw") + assert with_bool == with_string + + +def test_bool_false_becomes_lowercase_string() -> None: + with_bool = sign({"A": False}, "pw") + with_string = sign({"A": "false"}, "pw") + assert with_bool == with_string + + +def test_int_amount_stringified_without_quotes_semantics() -> None: + """`Amount: 1111` (int) даёт тот же результат, что и `Amount: "1111"` (str).""" + with_int = sign({"Amount": 1111}, "pw") + with_str = sign({"Amount": "1111"}, "pw") + assert with_int == with_str + + +def test_float_without_leading_zero_loss_and_no_exponent() -> None: + """Дробное число сериализуется без экспоненты и без хвостовых нулей. + + Ключи после добавления Password: "A" < "Password" (лексикографически), + поэтому конкатенация — значение A, затем значение Password. + """ + raw = "1234.5" + "pw" + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert sign({"A": 1234.5}, "pw") == expected + + +def test_large_float_has_no_exponential_notation() -> None: + """Очень большое число не сваливается в экспоненциальную запись (`1e+21`).""" + raw = "1000000000000000000000" + "pw" + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert sign({"A": 1e21}, "pw") == expected + + +def test_none_values_are_skipped() -> None: + """`None`-поля не участвуют в конкатенации вообще (не как пустая строка).""" + with_none = sign({"A": "x", "B": None}, "pw") + without_key = sign({"A": "x"}, "pw") + assert with_none == without_key + + +def test_nested_receipt_dict_is_ignored() -> None: + """Вложенный `Receipt` (dict) не участвует в подписи.""" + without_receipt = sign({"A": "x"}, "pw") + with_receipt = sign({"A": "x", "Receipt": {"Email": "a@test.ru", "Items": []}}, "pw") + assert without_receipt == with_receipt + + +def test_nested_data_dict_is_ignored() -> None: + """Вложенный `DATA`/`Data` (dict) не участвует в подписи — оба варианта регистра ключа.""" + baseline = sign({"A": "x"}, "pw") + assert sign({"A": "x", "DATA": {"Phone": "+70000000000"}}, "pw") == baseline + assert sign({"A": "x", "Data": {"Phone": "+70000000000"}}, "pw") == baseline + + +def test_nested_list_items_is_ignored() -> None: + """Вложенный список (`Items`/`Shops` как root-ключ) не участвует в подписи.""" + baseline = sign({"A": "x"}, "pw") + assert sign({"A": "x", "Items": [{"Name": "тест"}]}, "pw") == baseline + assert sign({"A": "x", "Shops": [{"ShopCode": "1"}]}, "pw") == baseline + + +def test_existing_token_field_in_payload_is_excluded() -> None: + """Если в payload уже есть `Token` (например, переподписываем нотификацию) — игнорируется.""" + without_token = sign({"A": "x"}, "pw") + with_token = sign({"A": "x", "Token": "stale-value-from-previous-signing"}, "pw") + assert without_token == with_token + + +def test_sort_is_by_key_name_not_insertion_order() -> None: + """Порядок вставки ключей в payload не влияет на результат — сортировка по ключу.""" + forward = sign({"Zeta": "1", "Alpha": "2", "Mid": "3"}, "pw") + reversed_order = sign({"Mid": "3", "Alpha": "2", "Zeta": "1"}, "pw") + assert forward == reversed_order + + # sanity: строка действительно собрана в алфавитном порядке ключей. + # Ключи с Password: Alpha < Mid < Password < Zeta (лексикографически). + raw = "".join(["2", "3", "pw", "1"]) # Alpha->2, Mid->3, Password->pw, Zeta->1 + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert forward == expected diff --git a/tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py b/tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py new file mode 100644 index 00000000..ae5fefe1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py @@ -0,0 +1,46 @@ +"""#2616 шаг 1: каждый прямой BrowserFetcher(...) в pipeline.py обязан прокидывать +environment=. + +Ревью PR #2634 нашло: build_browser_fetcher() (providers/_base.py) — единственное +место, где environment прокидывался, а avito-пути pipeline.py конструируют +BrowserFetcher напрямую → guard NoProxyAvailableError был мёртв ровно для +источника, ради которого писался (#2613). Юнит-тесты этого не ловили, потому что +конструировали фетчер сами, а не путём pipeline. + +Source-level гард дешевле интеграционного прогона pipeline и падает на первом же +новом construction-site без environment=. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_PIPELINE = ( + Path(__file__).resolve().parents[2] + / "packages" + / "scraper-kit" + / "src" + / "scraper_kit" + / "orchestration" + / "pipeline.py" +) + + +def test_every_direct_browserfetcher_construction_threads_environment() -> None: + src = _PIPELINE.read_text(encoding="utf-8") + # Все вызовы конструктора BrowserFetcher( ... ) — балансировку скобок не пишем, + # берём консервативное окно в 15 строк после открытия вызова. + sites = [m.start() for m in re.finditer(r"BrowserFetcher\(\n", src)] + assert sites, "pipeline.py больше не конструирует BrowserFetcher напрямую? Обнови/удали тест." + missing: list[str] = [] + for pos in sites: + window = src[pos : pos + 700] + if "environment=" not in window: + line_no = src[:pos].count("\n") + 1 + missing.append(f"pipeline.py:{line_no}") + assert not missing, ( + f"Прямые BrowserFetcher(...) без environment= ({missing}): guard " + "NoProxyAvailableError (#2616) для них мёртв — добавь " + 'environment=getattr(config, "environment", "dev").' + ) diff --git a/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py b/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py index 3d9f3405..dff4a4ad 100644 --- a/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py +++ b/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py @@ -3,10 +3,13 @@ Покрывает инвариант ship-dark + fallback на уровне helper'а `curl_proxy_url` и class-based провайдера (YandexValuationScraper): - флаг off / proxy_provider=None → env-прокси, пул не трогается (golden-parity); - - флаг on + пул пуст (acquire→None) → fallback env, не падаем; - - флаг on + lease → fetch через lease.url, mark_health вызван, release в finally; + - флаг on + пул пуст (acquire→None) + dev → fallback env, не падаем; + - флаг on + пул пуст (acquire→None) + prod (#2616 шаг 1) → NoProxyAvailableError, + HTTP-запрос НЕ выполняется, на env НЕ идём; + - флаг on + lease → fetch через lease.url, mark_health вызван, release в finally + (в prod и dev одинаково — пул выдал лизу, отказа быть не должно); - исключение внутри блока → mark_health(ok=False) + release всё равно (lease не течёт); - - acquire кинул → fallback env (сбор не ломаем). + - acquire кинул → fallback env (dev) / NoProxyAvailableError (prod). """ from __future__ import annotations @@ -22,6 +25,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/ from scraper_kit.contracts import ProxyLease from scraper_kit.providers._proxy import curl_proxy_url +from scraper_kit.proxy_errors import NoProxyAvailableError, ProxyBanError # ── Фейки ───────────────────────────────────────────────────────────────────── @@ -30,6 +34,9 @@ from scraper_kit.providers._proxy import curl_proxy_url class _FakeConfig: use_proxy_pool_curl: bool = False scraper_proxy_url: str | None = None + # #2616 шаг 1: дефолт "dev" — существующие тесты (не задающие поле явно) не + # затрагиваются новым prod-отказом, ведут себя ровно как до этого изменения. + environment: str = "dev" class _SpyProvider: @@ -41,6 +48,7 @@ class _SpyProvider: self.acquire_calls: list[str] = [] self.release_calls: list[int] = [] self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] def acquire(self, provider: str) -> ProxyLease | None: self.acquire_calls.append(provider) @@ -56,6 +64,9 @@ class _SpyProvider: ) -> None: self.mark_health_calls.append((lease.id, ok)) + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + self.mark_banned_calls.append((lease.id, source)) + _LEASE = ProxyLease(id=7, url="http://user:pass@pool-proxy:3128", kind="http", rotate_url=None) @@ -113,6 +124,41 @@ def test_flag_on_exception_marks_fail_and_still_releases() -> None: assert spy.release_calls == [7] +def test_ban_exception_calls_mark_banned_in_addition_to_mark_health() -> None: + """Исключение — подкласс ProxyBanError (напр. AvitoBlockedError) внутри блока — + вызывает mark_banned(lease, source=provider) В ДОПОЛНЕНИЕ к mark_health(ok=False) + (#2600 п.1 curl-путь). Zero изменений в вызывающем коде — сигнал детектируется + по ТИПУ исключения, а не явным вызовом.""" + + class _FakeBlockedError(ProxyBanError): + pass + + cfg = _FakeConfig(use_proxy_pool_curl=True) + spy = _SpyProvider(_LEASE) + with pytest.raises(_FakeBlockedError): + with curl_proxy_url(cfg, spy, "avito", env_fallback_url=None) as url: + assert url == _LEASE.url + raise _FakeBlockedError("firewall page detected") + assert spy.mark_banned_calls == [(7, "avito")] + assert spy.mark_health_calls == [(7, False)] # оба сигнала, не взаимоисключающие + assert spy.release_calls == [7] # lease всё равно освобождён + + +def test_plain_exception_does_not_call_mark_banned() -> None: + """Обычная (не-ProxyBanError) ошибка — сетевой сбой/таймаут — идёт ТОЛЬКО через + mark_health(ok=False), mark_banned НЕ вызывается (issue #2600 п.4 — различимость + «бан площадки» vs «сетевой сбой»).""" + cfg = _FakeConfig(use_proxy_pool_curl=True) + spy = _SpyProvider(_LEASE) + with pytest.raises(TimeoutError): + with curl_proxy_url(cfg, spy, "cian", env_fallback_url=None) as url: + assert url == _LEASE.url + raise TimeoutError("connect timed out") + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(7, False)] + assert spy.release_calls == [7] + + def test_acquire_raises_falls_back_to_env() -> None: cfg = _FakeConfig(use_proxy_pool_curl=True) spy = _SpyProvider(_LEASE, acquire_raises=True) @@ -121,6 +167,86 @@ def test_acquire_raises_falls_back_to_env() -> None: assert spy.release_calls == [] +# ── #2616 шаг 1: пул пуст в prod → отказ, НЕ мёртвый env-фолбэк ──────────────── + + +def test_flag_on_empty_pool_dev_falls_back_to_env() -> None: + """Пул пуст + явный dev-признак → прежнее поведение (env-фолбэк, не падаем).""" + cfg = _FakeConfig(use_proxy_pool_curl=True, environment="dev") + spy = _SpyProvider(None) + with curl_proxy_url(cfg, spy, "avito", env_fallback_url="http://env:3128") as url: + assert url == "http://env:3128" + assert spy.acquire_calls == ["avito"] + assert spy.mark_health_calls == [] + assert spy.release_calls == [] + + +def test_flag_on_empty_pool_prod_refuses_no_http_request() -> None: + """Пул пуст + прод-признак → NoProxyAvailableError, HTTP-запрос НЕ выполняется. + + Заглушка `_boom` падает на ЛЮБОМ вызове внутри `with`-блока (там, где в + реальном коде было бы `session.get(...)`). Если бы curl_proxy_url тихо + fallback'нулся на env (регрессия), `_boom()` выполнился бы и поднял + AssertionError, который `pytest.raises(NoProxyAvailableError)` НЕ поймает — + тест упал бы с несовпадающим типом исключения (falsifiable). + """ + cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production") + spy = _SpyProvider(None) # acquire → None (пул пуст) + + def _boom() -> None: + raise AssertionError("HTTP request must NOT happen — proxy pool empty in prod") + + with pytest.raises(NoProxyAvailableError): + with curl_proxy_url(cfg, spy, "avito", env_fallback_url="http://env:3128"): + _boom() # НЕ должно достигаться — raise происходит ДО yield + + assert spy.acquire_calls == ["avito"] + # lease не выдан → mark_health/release не зовём (нечего освобождать) + assert spy.mark_health_calls == [] + assert spy.release_calls == [] + + +def test_acquire_raises_prod_refuses_no_env_fallback() -> None: + """acquire() упал + прод-признак → NoProxyAvailableError, не мёртвый env.""" + cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production") + spy = _SpyProvider(_LEASE, acquire_raises=True) + with pytest.raises(NoProxyAvailableError): + with curl_proxy_url(cfg, spy, "cian", env_fallback_url="http://env:3128"): + pytest.fail("must not enter with-block body") + assert spy.release_calls == [] + + +def test_flag_on_lease_prod_unaffected() -> None: + """Пул выдал прокси в прод — поведение БЕЗ ИЗМЕНЕНИЙ (не наш случай отказа).""" + cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production") + spy = _SpyProvider(_LEASE) + with curl_proxy_url(cfg, spy, "yandex", env_fallback_url="http://env:3128") as url: + assert url == _LEASE.url + assert spy.acquire_calls == ["yandex"] + assert spy.mark_health_calls == [(7, True)] + assert spy.release_calls == [7] + + +def test_no_proxy_error_distinguishable_from_site_block() -> None: + """Причина отказа ("нет прокси", наша инфраструктура) programmatically отличима + от блокировки площадкой: отдельный exception-тип (не AvitoBlockedError/generic), + структурный provider-атрибут, текст без "blocked"/"captcha"/"banned". + """ + cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production") + spy = _SpyProvider(None) + with pytest.raises(NoProxyAvailableError) as exc_info: + with curl_proxy_url(cfg, spy, "cian", env_fallback_url="http://env:3128"): + pass + err = exc_info.value + assert err.provider == "cian" + assert not isinstance(err, LookupError) # не путается с "не найдено"-семантикой + lowered = str(err).lower() + assert "blocked" not in lowered + assert "captcha" not in lowered + assert "banned" not in lowered + assert "no proxy available" in lowered + + # ── YandexValuationScraper: lease держится на всё время сессии ───────────────── diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index 601287f3..ea0837dd 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -34,7 +34,7 @@ from fastapi.testclient import TestClient from app.api.v1 import me as me_router from app.core import auth as auth_mod -from app.core.rbac import rbac_guard +from app.core.rbac import _db_role_path_allowed, rbac_guard @pytest.fixture(autouse=True) @@ -69,10 +69,30 @@ def _build_test_app() -> FastAPI: async def brand_dummy() -> dict: return {"ok": True} + # Внутренние инструменты, закрытые от клиентских ролей 2026-07-31 + # (см. _INTERNAL_TOOL_PATHS ниже): API «Доли в продаже» и «Кэша». + @app.get("/api/v1/buildings/sale-share") + async def buildings_sale_share() -> dict: + return {"ok": True} + + @app.get("/api/v1/trade-in/cache-stats") + async def tradein_cache_stats() -> dict: + return {"ok": True} + @app.get("/health") async def health() -> dict: return {"status": "ok"} + # Анонимная поддержка (инцидент 2026-07-31) — публичная ветка рядом с + # авторизованной, чтобы тесты ниже проверяли ИМЕННО границу между ними. + @app.get("/api/v1/trade-in/support/anon/unread") + async def anon_support_unread() -> dict: + return {"unread": 0} + + @app.get("/api/v1/trade-in/support/unread") + async def support_unread() -> dict: + return {"unread": 0} + return app @@ -90,7 +110,9 @@ def test_get_role_known_users() -> None: assert auth_mod.get_role("admin") == "admin" assert auth_mod.get_role("kopylov") == "pilot" for n in range(1, 11): - assert auth_mod.get_role(f"user{n}") == "pilot" + # user2 («Брусника») — доступ закрыт 2026-07-30 (#2548) + expected = "expired" if n == 2 else "pilot" + assert auth_mod.get_role(f"user{n}") == expected def test_get_role_unknown_user_raises() -> None: @@ -251,6 +273,24 @@ def test_rbac_guard_skips_health(client: TestClient) -> None: assert resp.json()["status"] == "ok" +def test_rbac_guard_lets_anon_support_through_without_identity(client: TestClient) -> None: + """Инцидент 2026-07-31: поддержка должна работать БЕЗ входа — иначе тот, кто + не может залогиниться, не может и пожаловаться на это.""" + resp = client.get("/api/v1/trade-in/support/anon/unread") + assert resp.status_code == 200 + assert resp.json() == {"unread": 0} + + +def test_rbac_guard_still_gates_authenticated_support(client: TestClient) -> None: + """Обратная сторона той же границы: анонимная ветка НЕ распахнула соседний + авторизованный support (тред залогиненного юзера по-прежнему за identity).""" + assert client.get("/api/v1/trade-in/support/unread").status_code == 401 + resp = client.get( + "/api/v1/trade-in/support/unread", headers={"X-Authenticated-User": "nosuchuser"} + ) + assert resp.status_code == 403 + + def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None: resp = client.get( "/api/v1/me", @@ -325,3 +365,168 @@ def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) resp = client.get("/api/v1/me") assert resp.status_code == 401 assert "no authenticated user" in resp.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# 2026-07-31: внутренние разделы («Доля в продаже» / «Кэш») закрыты от клиентов +# --------------------------------------------------------------------------- +# +# Решение владельца продукта: оба раздела — внутренние инструменты (аналитика +# рынка / состояние кэшей и скраперов), клиентские аккаунты их видеть не должны +# (триггер — praktika, DB-роль manager, у которого оба пункта висели в топбаре). +# Deny заведён в DB_ROLE_PATHS (employee/manager) и зеркально в pilot.deny +# (auth/roles.yaml) — страницы И их API, чтобы гейт сработал сразу в трёх местах: +# пункт меню (Topbar через /me), страница (RouteGuard), ручки (rbac_guard). + +# Внешние пути (как их видит RBAC-конфиг): 2 страницы + все API раздела. +# Проверяются матчерами напрямую — регистрировать их в тестовом app не нужно. +_INTERNAL_TOOL_PATHS = ( + "/trade-in/sale-share", + "/trade-in/cache", + "/trade-in/api/v1/buildings/sale-share", + # Остальные ручки роутера buildings.py — глоб '/…/buildings/**' обязан + # покрывать и их, включая параметризованную (самый вероятный кандидат на + # переезд под другой префикс — тогда этот тест упадёт, а не промолчит). + "/trade-in/api/v1/buildings/sale-share/summary", + "/trade-in/api/v1/buildings/123/listings", + "/trade-in/api/v1/trade-in/cache-stats", + # Трейлинг-слэш: точный паттерн его НЕ ловил (allowed=True), защита висела + # на Starlette redirect_slashes — поэтому deny переведён на глоб-форму. + "/trade-in/api/v1/trade-in/cache-stats/", +) + +# Разрез тех же путей по ПРОДУКТАМ — нужен для ролей, у которых доступ +# асимметричен (см. test_yaml_roles_deliberately_outside_client_deny). +# «Поиск домов» — тестовый продукт, admin-only (решение владельца 2026-07-31). +_SALE_SHARE_PATHS = ( + "/trade-in/sale-share", + "/trade-in/api/v1/buildings/sale-share", + "/trade-in/api/v1/buildings/sale-share/summary", + "/trade-in/api/v1/buildings/123/listings", +) +# «Кэш» — не продукт, а диагностика состояния кэшей/скраперов. +_CACHE_TOOL_PATHS = ( + "/trade-in/cache", + "/trade-in/api/v1/trade-in/cache-stats", + "/trade-in/api/v1/trade-in/cache-stats/", +) + +# Разрез обязан покрывать исходный набор целиком — иначе новый внутренний путь +# добавят в _INTERNAL_TOOL_PATHS, забудут отнести к продукту, и роль analyst +# останется непроверенной на нём. +assert set(_SALE_SHARE_PATHS) | set(_CACHE_TOOL_PATHS) == set(_INTERNAL_TOOL_PATHS) + +# Основной продукт — не должен быть задет deny выше. +_CORE_PRODUCT_PATHS = ("/trade-in/", "/trade-in/api/v1/trade-in/estimate") + + +def test_db_roles_denied_on_internal_tool_paths() -> None: + """manager/employee (DB-роли, session-auth ветка rbac_guard) → deny.""" + for role in ("manager", "employee"): + for path in _INTERNAL_TOOL_PATHS: + assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}" + + +def test_db_admin_still_allowed_on_internal_tool_paths() -> None: + for path in _INTERNAL_TOOL_PATHS: + assert _db_role_path_allowed("admin", path), f"admin lost access to {path}" + + +def test_yaml_roles_deliberately_outside_client_deny() -> None: + """Пиннит ОБРАТНУЮ сторону правки 2026-07-31: роли, которые сознательно НЕ + попали под клиентский deny. + + Без этого теста «синхронизация» deny-списков между ролями в auth/roles.yaml + (соблазн скопировать pilot.deny в соседей) молча отрезала бы админа от его + же инструментов, и ни один тест бы не упал: roles.yaml лежит ВНЕ paths-фильтров + `backend/**` и `tradein-mvp/**`, т.е. CI такую правку не проверяет вовсе — + ровно тот класс рассинхрона, что уже случился с user2 (см. + backend/tests/test_rbac.py::test_get_role_known_users). + + `analyst` (внутренняя роль, paths "/**") попадает под клиентский deny + ЧАСТИЧНО, и обе стороны асимметрии здесь запиннены намеренно: + - «Поиск домов» ЗАКРЫТ — тестовый продукт, доступ только у admin + (решение владельца 2026-07-31; «только у админа» включает и внутренние + роли, поэтому analyst тоже в deny); + - «Кэш» ОТКРЫТ — это не продукт, а диагностика кэшей/скраперов, ровно тот + инструмент, ради которого роль analyst и заведена. + Если решение поменяется — упадёт этот тест, а не пользователь на проде. + """ + for path in _INTERNAL_TOOL_PATHS: + assert auth_mod.is_path_allowed("admin", path), f"admin lost access to {path}" + + for path in _SALE_SHARE_PATHS: + assert not auth_mod.is_path_allowed("analyst", path), ( + f"analyst не должен видеть «Поиск домов» ({path}) — тестовый продукт, " + f"admin-only; если решение изменилось, обнови тест И комментарий у роли " + f"analyst в auth/roles.yaml" + ) + + for path in _CACHE_TOOL_PATHS: + assert auth_mod.is_path_allowed("analyst", path), ( + f"analyst потерял «Кэш» ({path}) — это его рабочий инструмент; если " + f"закрыли намеренно, обнови тест И комментарий у роли analyst" + ) + + +def test_db_roles_still_allowed_on_core_product() -> None: + """Регресс: оценка (основной продукт) для клиентских ролей не задета.""" + for role in ("manager", "employee"): + for path in _CORE_PRODUCT_PATHS: + assert _db_role_path_allowed(role, path), f"{role} lost access to {path}" + + +def test_legacy_pilot_denied_on_internal_tool_paths() -> None: + """Зеркало в auth/roles.yaml: пока auth_mode=dual, legacy-pilot не должен + видеть то, что DB-employee уже не видит.""" + for path in _INTERNAL_TOOL_PATHS: + assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}" + for path in _CORE_PRODUCT_PATHS: + assert auth_mod.is_path_allowed("pilot", path), f"pilot lost access to {path}" + + +def test_rbac_guard_blocks_pilot_on_internal_tool_api(client: TestClient) -> None: + """Тот же deny через РЕАЛЬНЫЙ guard (legacy trusted-header ветка): ручки + sale-share/кэша отдают 403, а не только прячутся из меню.""" + for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"): + resp = client.get(path, headers={"X-Authenticated-User": "kopylov"}) + assert resp.status_code == 403, f"pilot {path}: {resp.status_code}" + assert "forbidden for role" in resp.json()["detail"].lower() + + +def test_rbac_guard_admin_keeps_internal_tool_api(client: TestClient) -> None: + for path in ("/api/v1/buildings/sale-share", "/api/v1/trade-in/cache-stats"): + resp = client.get(path, headers={"X-Authenticated-User": "admin"}) + assert resp.status_code == 200, f"admin {path}: {resp.text}" + + +def test_internal_deny_globs_do_not_leak_to_sibling_prefixes() -> None: + """Граничный случай: '/**' компилируется в '^(?:/.*)?$' — + матчит сам prefix, prefix со слэшем и подпути через '/', но НЕ соседей по + префиксу (дефис не матчится). Именно поэтому глоб-форма безопасна как + замена точного пути: '/trade-in/cache/**' не задевает страницу + '/trade-in/cache-stats', а '/…/trade-in/cache-stats/**' — не гипотетическую + '/…/trade-in/cache-statistics'. Фиксируем семантику тестом: если её однажды + поменяют (напр. на префиксный startswith), соседние пути начнут молча + падать в 403.""" + siblings_allowed = ( + "/trade-in/cache-stats", + "/trade-in/sale-share-report", + "/trade-in/api/v1/trade-in/cache-statistics", + ) + section_denied = ( + "/trade-in/cache/detail", + "/trade-in/sale-share/123", + "/trade-in/api/v1/trade-in/cache-stats/reset", + ) + for role in ("manager", "employee"): + for path in siblings_allowed: + assert _db_role_path_allowed(role, path), f"{role} lost sibling {path}" + # ...при том что сам раздел и его подпути закрыты. + for path in section_denied: + assert not _db_role_path_allowed(role, path), f"{role} must not reach {path}" + + for path in siblings_allowed: + assert auth_mod.is_path_allowed("pilot", path), f"pilot lost sibling {path}" + for path in section_denied: + assert not auth_mod.is_path_allowed("pilot", path), f"pilot must not reach {path}" diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 39472da2..fcd95207 100644 --- a/tradein-mvp/backend/tests/test_sales_vs_listings.py +++ b/tradein-mvp/backend/tests/test_sales_vs_listings.py @@ -8,6 +8,8 @@ Covers: - linkage_rate_pct computation. - median_discount_pct on subset с listing_id != None. - extract_street_name failure → returns empty response with street=None. + - #2666 гейт правдоподобия median_discount_pct: мало пар / значение вне + санитарного диапазона → числа нет, но есть median_discount_explanation. """ import os @@ -101,6 +103,26 @@ def _make_pair_row( } +def _rows_with_discounts(discounts: list[float]) -> list[dict]: + """N пар с заданными discount_pct (deal_id/listing_id уникальны).""" + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + i, discount_pct=d) + for i, d in enumerate(discounts) + ] + + +def _rows_on_shared_listings(discounts: list[float], n_listings: int) -> list[dict]: + """N пар, разложенных по n_listings РАЗЛИЧНЫМ объявлениям (#2672). + + Псевдореплики из прода: сделки уникальны, а listing_id переиспользуется — + ровно то, что `_rows_with_discounts` (одно объявление на пару) не выражает. + """ + return [ + _make_pair_row(deal_id=1000 + i, listing_id=2000 + (i % n_listings), discount_pct=d) + for i, d in enumerate(discounts) + ] + + def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None: from app.core.db import get_db @@ -198,7 +220,11 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 1 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 100.0 - assert data["median_discount_pct"] == -5.77 + # #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а + # вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия. + assert data["median_discount_pct"] is None + # «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10"). + assert "всего 1 —" in data["median_discount_explanation"] assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 @@ -251,8 +277,9 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: assert data["total_deals"] == 2 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 50.0 - # median считается только по парам с discount_pct - assert data["median_discount_pct"] == -5.0 + # median считается только по парам с discount_pct — но одной пары мало, + # #2666 гейт её гасит (сам LEFT JOIN это не ломает). + assert data["median_discount_pct"] is None # Pair without listing pair_no_listing = next(p for p in data["pairs"] if p["deal_id"] == 1002) assert pair_no_listing["listing_id"] is None @@ -265,14 +292,16 @@ def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: - """Median считается через _percentile(0.5) только по парам c discount_pct.""" - # Discounts: [-10, -5, 0, 3, 7] → median = 0 + """Median считается через _percentile(0.5) только по парам c discount_pct. + + 11 пар (≥ MIN_PAIRS #2666) с рыночной медианой — число доходит до ответа, + объяснения нет. + """ + # Discounts: 11 значений, средний (индекс 5) = -17.0 → median = -17.0 fixture_rows = [ - _make_pair_row(deal_id=1, listing_id=11, discount_pct=-10.0), - _make_pair_row(deal_id=2, listing_id=12, discount_pct=-5.0), - _make_pair_row(deal_id=3, listing_id=13, discount_pct=0.0), - _make_pair_row(deal_id=4, listing_id=14, discount_pct=3.0), - _make_pair_row(deal_id=5, listing_id=15, discount_pct=7.0), + *_rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), # Сделка без listing — не учитывается в median. _make_pair_row( deal_id=6, @@ -295,10 +324,11 @@ def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: ) assert resp.status_code == 200 data = resp.json() - assert data["total_deals"] == 6 - assert data["deals_with_listings"] == 5 - assert round(data["linkage_rate_pct"], 1) == 83.3 - assert data["median_discount_pct"] == 0.0 + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 11 + assert round(data["linkage_rate_pct"], 1) == 91.7 + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None # ── Test: SQL function called with proper params ───────────────────────────── @@ -335,6 +365,56 @@ def test_sales_vs_listings_passes_proper_params(trade_in_app: FastAPI) -> None: assert params["period_months"] == 12 +# ── Test: city-scope propagation (#2583 H4) ─────────────────────────────────── + + +def test_sales_vs_listings_passes_resolved_target_city(trade_in_app: FastAPI) -> None: + """#2583 H4: адрес с распознаваемым городом (словарь SVERDLOVSK_OBLAST_CITIES) + должен прокидывать target_city в street_sales_vs_listings() — иначе пары + склеиваются с другими городами (зеркало /street-deals #C1).""" + db_mock = _make_db_mock([]) + _override_db(trade_in_app, db_mock) + + client = TestClient(trade_in_app) + resp = client.get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "Нижний Тагил, ул. Ленина, 5", + "area_m2": 44.3, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + assert db_mock.execute.called + args, kwargs = db_mock.execute.call_args + params = args[1] if len(args) > 1 else kwargs.get("parameters", {}) + assert params["target_city"] == "нижний тагил" + + +def test_sales_vs_listings_target_city_none_when_city_unresolved( + trade_in_app: FastAPI, +) -> None: + """Адрес вне словаря SVERDLOVSK_OBLAST_CITIES (известная H1) → target_city=None, + TVF-сторона не фильтрует по городу — тот же fallback, что и /street-deals.""" + db_mock = _make_db_mock([]) + _override_db(trade_in_app, db_mock) + + client = TestClient(trade_in_app) + resp = client.get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "Верхняя Синячиха, ул. Ленина, 5", + "area_m2": 44.3, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + assert db_mock.execute.called + args, kwargs = db_mock.execute.call_args + params = args[1] if len(args) > 1 else kwargs.get("parameters", {}) + assert params["target_city"] is None + + # ── Test: response shape (Pydantic validation) ─────────────────────────────── @@ -367,6 +447,7 @@ def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: "deals_with_listings", "linkage_rate_pct", "median_discount_pct", + "median_discount_explanation", "pairs", } assert expected_keys.issubset(data.keys()) @@ -456,3 +537,208 @@ def test_sales_vs_listings_defaults(trade_in_app: FastAPI) -> None: assert data["window_days"] == 180 assert data["area_tolerance"] == 0.15 assert data["period_months"] == 24 + + +# ── Test: #2666 гейт правдоподобия median_discount_pct ─────────────────────── + + +def _get_sales(trade_in_app: FastAPI, rows: list[dict]) -> dict: + """GET /sales-vs-listings на фиксированном адресе, вернуть JSON.""" + _override_db(trade_in_app, _make_db_mock(rows)) + resp = TestClient(trade_in_app).get( + "/api/v1/trade-in/sales-vs-listings", + params={ + "address": "г. Екатеринбург, ул. Космонавтов, 50", + "area_m2": 50.0, + "rooms": 2, + }, + ) + assert resp.status_code == 200 + return resp.json() + + +def test_median_discount_gated_when_too_few_pairs(trade_in_app: FastAPI) -> None: + """#2666: пар меньше MIN_PAIRS → числа нет, но есть объяснение почему. + + Прод-бутстрап (2026-08-05): на 9 парах p90 отклонения медианы подвыборки от + полной ≈ 12-19 п.п. — такое число нельзя показывать как «медианный торг». + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 9)) + assert data["deals_with_listings"] == 9 + assert data["median_discount_pct"] is None + # Объяснение называет ФАКТИЧЕСКОЕ число пар — иначе оно бесполезно. И НЕ + # обещает надёжность выше порога: 10 пар тоже не гарантия (ревью #2671). + assert "всего 9 —" in data["median_discount_explanation"] + assert "надёжн" not in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_pairs_boundary(trade_in_app: FastAPI) -> None: + """MIN_PAIRS включительно: ровно 10 пар — число ещё отдаётся.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-12.0] * 10)) + assert data["median_discount_pct"] == -12.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gated_when_all_pairs_share_one_listing(trade_in_app: FastAPI) -> None: + """#2672: 30 пар, но ОДНО различное объявление → числа нет. + + Живой кейс (`Академика Ландау` 1-комн., −12.36%) выбран намеренно «скучным»: + пар втрое больше порога, значение рыночное — ни один из старых признаков не + срабатывает, гасит ровно новый. 30 сделок, делённые на одну цену предложения, + это не 30 наблюдений рынка, а одно. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 30, n_listings=1)) + assert data["deals_with_listings"] == 30 + assert data["median_discount_pct"] is None + # Объяснение обязано назвать ОБА числа — «сделок 30» без «объявлений 1» + # читается как «данных достаточно», т.е. ровно наоборот. + assert "сделок 30" in data["median_discount_explanation"] + assert "всего 1" in data["median_discount_explanation"] + + +def test_median_discount_one_listing_gated_before_range(trade_in_app: FastAPI) -> None: + """#2672: `Белинского` 1-комн. — 50 пар, 1 объявление, −50.6%. + + Проходил старый гейт целиком (пар с запасом, −50.6% внутри старых [−60,+20]). + Сработать могли бы обе новые проверки; порядок закреплён: причина — «одно + объявление», а не «неправдоподобное значение». Она первична: пока выборка + стоит на одной цене, разбирать правдоподобность результата нечего. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-50.64] * 50, n_listings=1)) + assert data["median_discount_pct"] is None + assert "всего 1" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + +def test_median_discount_kosmonavtov_117_pairs_two_listings(trade_in_app: FastAPI) -> None: + """#2672 кейс, ради которого всё затевалось: 117 пар → 2 различных объявления. + + Порог по объявлениям здесь НЕ срабатывает (2 ≥ 2, граница включительная) — + гасит ужесточённая нижняя граница: −37.6% глубже −35% и в полтора раза глубже + худшего объяснимого рынком бакета. Старый код показывал это число. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-37.57] * 117, n_listings=2)) + assert data["deals_with_listings"] == 117 + assert data["median_discount_pct"] is None + assert "неправдоподобное" in data["median_discount_explanation"] + assert "всего 2" not in data["median_discount_explanation"] + + +def test_median_discount_kept_at_min_distinct_listings_boundary(trade_in_app: FastAPI) -> None: + """Граница MIN_DISTINCT_LISTINGS включительная: 2 объявления — число отдаётся. + + Сторожит противоположное направление: гейт считает ОБЪЯВЛЕНИЯ, но не должен + съесть всё, что не идеально. 117 пар на 2 объявлениях с рыночной медианой + (вторая группа Космонавтова, −12.98%) проходят. + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2)) + assert data["median_discount_pct"] == -12.98 + assert data["median_discount_explanation"] is None + + +def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None: + """#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице. + + Ровно кейс из issue (`%Космонавтов%` 2-комн., +36.4% после гарда #2660). + Пар достаточно, гасит именно санитарный диапазон. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["deals_with_listings"] == 11 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + assert "36" in data["median_discount_explanation"] + + +def test_median_discount_gated_when_implausibly_negative(trade_in_app: FastAPI) -> None: + """Нижняя граница диапазона: −70% в 3 раза глубже худшего городского + asking→sold бакета (студии, ratio 0.7634 = −23.7%) — тоже не рынок.""" + data = _get_sales(trade_in_app, _rows_with_discounts([-70.0] * 11)) + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] + + +def test_median_discount_gated_below_tightened_floor(trade_in_app: FastAPI) -> None: + """#2672: −46.5% гасится, хотя старую границу −60% проходил. + + Живой кейс из ревью #2671: Серов, ул. Ленина 163, 2-комн., 21 пара — + показывался «медианный торг −46.54%» при том, что худший объяснимый рынком + бакет = −23.7%. Пар вдвое больше порога, объявлений хватает — гасит именно + ужесточённая нижняя граница, и только она. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-46.54] * 21)) + assert data["median_discount_pct"] is None + assert "неправдоподобное" in data["median_discount_explanation"] + assert "46" in data["median_discount_explanation"] + + +def test_median_discount_kept_at_sane_range_boundaries(trade_in_app: FastAPI) -> None: + """Границы санитарного диапазона включительные: +20.0% и −35.0% проходят.""" + top = _get_sales(trade_in_app, _rows_with_discounts([20.0] * 11)) + assert top["median_discount_pct"] == 20.0 + assert top["median_discount_explanation"] is None + + bottom = _get_sales(trade_in_app, _rows_with_discounts([-35.0] * 11)) + assert bottom["median_discount_pct"] == -35.0 + assert bottom["median_discount_explanation"] is None + + +def test_median_discount_normal_case_unchanged(trade_in_app: FastAPI) -> None: + """Нормальный случай (пар хватает, значение рыночное) — число как прежде.""" + data = _get_sales( + trade_in_app, + _rows_with_discounts( + [-25.0, -23.0, -21.0, -20.0, -19.0, -17.0, -16.0, -15.0, -13.0, -11.0, -9.0] + ), + ) + assert data["median_discount_pct"] == -17.0 + assert data["median_discount_explanation"] is None + + +def test_median_discount_explanation_absent_when_no_pairs_at_all( + trade_in_app: FastAPI, +) -> None: + """Сделки есть, но ни одной пары → медианы просто НЕТ, объяснять нечего. + + Отличать «не посчиталось» от «посчиталось и погашено гейтом» обязан фронт: + он рендерит объяснение вместо числа, и текст «медиана гуляет» на улице без + единого объявления был бы враньём. + """ + rows = [ + _make_pair_row( + deal_id=1000 + i, + listing_id=None, + listing_price_rub=None, + discount_pct=None, + ) + for i in range(12) + ] + data = _get_sales(trade_in_app, rows) + assert data["total_deals"] == 12 + assert data["deals_with_listings"] == 0 + assert data["median_discount_pct"] is None + assert data["median_discount_explanation"] is None + + +def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> None: + """Порядок проверок: 3 пары по +80% — причина «мало пар», НЕ «вне диапазона». + + Обе проверки сработали бы, но «пар всего 3» информативнее и точнее: при + такой выборке значение вообще не заслуживает разбора на правдоподобность. + Тест закрепляет порядок — перестановка условий деградирует объяснение. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([80.0] * 3)) + assert data["median_discount_pct"] is None + assert "всего 3 —" in data["median_discount_explanation"] + assert "неправдоподобное" not in data["median_discount_explanation"] + + +def test_median_discount_gate_leaves_pairs_and_linkage_untouched( + trade_in_app: FastAPI, +) -> None: + """Гейт гасит ТОЛЬКО сводную медиану: linkage_rate_pct и per-pair discount_pct + остаются — это наблюдаемые факты, а не оценка по улице.""" + data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11)) + assert data["median_discount_pct"] is None + assert data["linkage_rate_pct"] == 100.0 + assert len(data["pairs"]) == 11 + assert all(p["discount_pct"] == 36.4 for p in data["pairs"]) diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index af5c3fd7..c15c49c4 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -383,10 +383,15 @@ def _make_listing(*, price_per_m2: float, area_m2: float = 60.0) -> dict[str, An # Радиусные аналоги — НИЗКИЕ (массовая застройка рядом размывает премиум). +# #oblast-E: 5 items (>= HEADLINE_LISTINGS_MIN_N) so the new headline +# sufficiency gate doesn't suppress this fixture's median (still 210_000, +# symmetric) before the anchor-vs-radius mechanic under test in this file runs. _RADIUS_ANALOGS: list[dict[str, Any]] = [ - _make_listing(price_per_m2=200_000.0), + _make_listing(price_per_m2=195_000.0), + _make_listing(price_per_m2=205_000.0), _make_listing(price_per_m2=210_000.0), - _make_listing(price_per_m2=220_000.0), + _make_listing(price_per_m2=215_000.0), + _make_listing(price_per_m2=225_000.0), ] # Same-building комплы Хохрякова 48 (флагман 684k внутри). @@ -651,11 +656,11 @@ def test_estimate_analogs_reflect_anchor_comps_when_fired() -> None: def test_estimate_analogs_stay_radius_when_no_anchor() -> None: """#694: якорь НЕ сработал (tier=None, Tier D) → est.analogs отражают радиусные - аналоги (_RADIUS_ANALOGS 200k-220k) — существующее поведение сохранено.""" + аналоги (_RADIUS_ANALOGS) — существующее поведение сохранено.""" est = _run_estimate(anchor_comps=[], anchor_tier=None) assert len(est.analogs) == len(_RADIUS_ANALOGS) ppm2_shown = {a.price_per_m2 for a in est.analogs} - assert ppm2_shown == {200_000, 210_000, 220_000} + assert ppm2_shown == {195_000, 205_000, 210_000, 215_000, 225_000} def test_estimate_analogs_pass_through_display_fields() -> None: diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 114322af..726d991e 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -28,6 +28,7 @@ from app.tasks import sber_freshness_monitor as mon _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql" +_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" # max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12). _MAY_2026 = date(2026, 5, 1) @@ -218,6 +219,40 @@ def test_migration_180_no_psycopg_trap() -> None: assert not re.search(r":\w+::", sql) +# ── Миграция 212: такт загрузки не должен пересекать порог монитора ─────────── +# +# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74 +# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника. +# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора. + + +def test_migration_212_makes_pull_cadence_weekly() -> None: + sql = _MIGRATION_212.read_text("utf-8") + assert "sber_index_pull" in sql + assert '"interval_days": 7' in sql + assert "BEGIN;" in sql and "COMMIT;" in sql + assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type) + + +def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None: + """Инвариант: пол возраста + такт загрузки < порога монитора. + + Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог = + sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас + 7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали. + """ + interval_days = int( + re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) + ) + observed_floor_days = 46 + threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS + assert observed_floor_days + interval_days < threshold, ( + f"такт {interval_days}д даёт потолок возраста " + f"{observed_floor_days + interval_days}д при пороге {threshold}д — " + "монитор снова будет мерить наш такт, а не застой источника" + ) + + # ── Регистрация в kit registry ───────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py index ed31e11c..61b7f1c7 100644 --- a/tradein-mvp/backend/tests/test_sber_index.py +++ b/tradein-mvp/backend/tests/test_sber_index.py @@ -469,10 +469,15 @@ async def test_pull_sber_indices_error_per_series_continues() -> None: @pytest.mark.asyncio -async def test_pull_sber_indices_404_logs_warning_with_path_hint( +async def test_pull_sber_indices_404_logs_error_with_path_hint( caplog: pytest.LogCaptureFixture, ) -> None: - """#902: a 404 logs a WARNING naming the slug + 'dataset-path invalid?' hint.""" + """#902: a 404 logs the slug + 'dataset-path invalid?' hint. + + #2674: уровень поднят WARNING → ERROR. 404 = переименованный slug, самая + ПЕРМАНЕНТНАЯ из трёх веток отказа (5xx и сетевой сбой проходят сами), а до этого + она была тише соседних и событием GlitchTip не становилась. + """ import logging import httpx @@ -498,11 +503,11 @@ async def test_pull_sber_indices_404_logs_warning_with_path_hint( ) assert result["errors"] == 1 - warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + errors = [r for r in caplog.records if r.levelno >= logging.ERROR] assert any( "residential_real_estate_prices" in r.message and "dataset-path invalid" in r.message - for r in warnings - ), f"Expected a 404 dataset-path warning, got: {[r.message for r in warnings]}" + for r in errors + ), f"Expected a 404 dataset-path ERROR, got: {[r.message for r in errors]}" @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py b/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py new file mode 100644 index 00000000..a9bfddba --- /dev/null +++ b/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py @@ -0,0 +1,171 @@ +"""Сохранение расписания в админке уважает такт источника (#2674). + +Баг: PUT /admin/scrape/schedules/{source} звал compute_next_run_at БЕЗ interval_days, +получал default=1 и ставил next_run_at на завтра — какой бы такт ни стоял в +default_params. Недельный avito_full_load после правки соседнего поля побежал бы через +сутки. На суточных источниках дефект невидим: для них «завтра» и есть верный ответ, +поэтому баг прожил до разбора #2674. + +Планировщик (scraper_kit.orchestration.scheduler._claim_run/_defer_next_run_at) такт +читал правильно — расходились именно два входа в одну и ту же формулу. + +Без сети, без БД: endpoint вызывается напрямую с mock-сессией, проверяются bind-params. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.api.v1.admin import update_schedule +from app.schemas.trade_in import ScheduleConfigUpdate + + +def _mock_db() -> MagicMock: + db = MagicMock() + row = { + "id": 1, + "source": "avito_full_load", + "enabled": True, + "window_start_hour": 13, + "window_end_hour": 15, + "default_params": {}, + "last_run_id": None, + "last_run_at": None, + "next_run_at": None, + "updated_at": None, + } + db.execute.return_value.mappings.return_value.fetchone.return_value = row + return db + + +def _saved_params(db: MagicMock) -> dict[str, Any]: + """Bind-params единственного execute в update_schedule.""" + return db.execute.call_args_list[0].args[1] + + +def _saved_sql(db: MagicMock) -> str: + return str(db.execute.call_args_list[0].args[0]) + + +def test_weekly_source_gets_next_run_in_a_week_not_tomorrow() -> None: + """Такт 7 → сохранение → next_run_at примерно через 7 суток. Падает на старом коде.""" + db = _mock_db() + before = datetime.now(tz=UTC) + + update_schedule( + "avito_full_load", + ScheduleConfigUpdate( + enabled=True, + window_start_hour=13, + window_end_hour=15, + default_params={"interval_days": 7, "concurrency": 1}, + ), + db, + ) + + next_at = _saved_params(db)["next_at"] + delta_days = (next_at - before).total_seconds() / 86400 + # Окно 13:00-15:00 внутри целевых суток → разброс ±1 сутки вокруг ровно 7. + assert 6.0 < delta_days < 8.0, f"ожидали ~7 суток, получили {delta_days:.2f}" + # Старое поведение (interval_days не передан → default=1) дало бы «завтра». + assert delta_days > 2.0, "next_run_at уехал на завтра — такт снова потерян" + + +def test_daily_source_still_runs_tomorrow() -> None: + """Такт 1 (и его отсутствие) — прежнее поведение, back-compat.""" + for params in ({}, {"interval_days": 1}): + db = _mock_db() + before = datetime.now(tz=UTC) + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params=params), + db, + ) + delta_days = (_saved_params(db)["next_at"] - before).total_seconds() / 86400 + assert 0.0 < delta_days < 2.0, f"params={params}: ожидали «завтра», got {delta_days:.2f}" + + +def test_null_interval_days_is_none_safe() -> None: + """`"interval_days": null` в jsonb → такт 1, а не TypeError (как в scheduler).""" + db = _mock_db() + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate( + window_start_hour=2, window_end_hour=5, default_params={"interval_days": None} + ), + db, + ) + assert _saved_params(db)["next_at"] is not None + + +def test_explicit_next_run_at_is_honored() -> None: + """Оператор явно задал момент — уважаем как есть, ничего не пересчитываем.""" + db = _mock_db() + wanted = datetime(2026, 9, 1, 3, 30, tzinfo=UTC) + + update_schedule( + "avito_full_load", + ScheduleConfigUpdate( + window_start_hour=13, + window_end_hour=15, + default_params={"interval_days": 7}, + next_run_at=wanted, + ), + db, + ) + + params = _saved_params(db) + assert params["next_at"] == wanted + assert params["explicit"] is True + + +def test_explicit_past_next_run_at_means_run_now() -> None: + """«Запустить сейчас» = момент в прошлом/now: планировщик берёт next_run_at <= NOW().""" + db = _mock_db() + now = datetime.now(tz=UTC) - timedelta(minutes=1) + + update_schedule( + "avito_full_load", + ScheduleConfigUpdate( + window_start_hour=13, window_end_hour=15, default_params={}, next_run_at=now + ), + db, + ) + + params = _saved_params(db) + assert params["next_at"] == now, "прошедший момент не должен подменяться пересчётом" + assert params["explicit"] is True + + +def test_no_explicit_next_run_at_flags_recompute() -> None: + """Без явного момента флаг explicit=false → SQL решает, двигать ли существующий.""" + db = _mock_db() + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params={}), + db, + ) + assert _saved_params(db)["explicit"] is False + + +def test_upsert_preserves_future_run_when_window_and_interval_unchanged() -> None: + """Shape-check: ON CONFLICT не перезаписывает будущий next_run_at без причины. + + Само ветвление живёт в SQL (CASE), проверить его исполнение офлайн нечем — здесь + сторожим, что ветка не исчезла из запроса при следующей правке. + """ + db = _mock_db() + update_schedule( + "cian_city_sweep", + ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params={}), + db, + ) + sql = _saved_sql(db) + assert "scrape_schedules.next_run_at > NOW()" in sql + assert "THEN scrape_schedules.next_run_at" in sql + assert "CAST(:explicit AS boolean)" in sql # psycopg v3: CAST, не :explicit::boolean diff --git a/tradein-mvp/backend/tests/test_scrape_run_alert.py b/tradein-mvp/backend/tests/test_scrape_run_alert.py index af8a3979..0ab3bcd0 100644 --- a/tradein-mvp/backend/tests/test_scrape_run_alert.py +++ b/tradein-mvp/backend/tests/test_scrape_run_alert.py @@ -1,9 +1,10 @@ -"""Unit tests for consecutive-failure Sentry alert in scrape_runs. +"""Unit tests for consecutive-failure / consecutive-zero-result Sentry alerts in +scrape_runs (#2625: business-result alert extension). All tests use a fully mocked DB session — no live DB required. The mock simulates db.execute(...).fetchall() and db.execute(...).fetchone() to control which run statuses are returned for the query in -_alert_if_consecutive_failures / _alert_on_run_id. +_alert_if_consecutive_failures / _alert_if_consecutive_zero_results / _alert_on_run_id. """ from __future__ import annotations @@ -13,12 +14,16 @@ from unittest.mock import MagicMock, patch from app.services.scrape_runs import ( CONSECUTIVE_FAILURE_ALERT_THRESHOLD, + CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD, _alert_if_consecutive_failures, + _alert_if_consecutive_zero_results, mark_banned, + mark_done, mark_failed, ) N = CONSECUTIVE_FAILURE_ALERT_THRESHOLD # 3 +NZ = CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD # 3 # --------------------------------------------------------------------------- @@ -201,3 +206,155 @@ class TestMarkBannedAlertIntegration: mark_banned(db, 42, "403 Forbidden", {}) mock_sentry.capture_message.assert_not_called() + + +# --------------------------------------------------------------------------- +# #2625: _alert_if_consecutive_zero_results — 'done' с lots_fetched=0, N подряд +# --------------------------------------------------------------------------- + + +# #2703: сторож читает counters, а НЕ колонку total_seen — у той DEFAULT 0, по ней +# «прогон не сообщил результата» неотличимо от «сообщил ноль». total_seen оставлен в +# фикстурах как исторический контекст: именно его читал прежний сторож. + + +def _zero_row() -> SimpleNamespace: + """Успешно завершённый прогон ('done') с ИЗМЕРЕННЫМ нулём лотов — капча/пустая + выдача-под-видом-успеха (#2625).""" + return SimpleNamespace(status="done", counters={"lots_fetched": 0}, total_seen=0) + + +def _nonzero_row(total_seen: int = 50) -> SimpleNamespace: + """Успешно завершённый прогон с реальным результатом — прерывает "нулевой" стрик.""" + return SimpleNamespace( + status="done", counters={"lots_fetched": total_seen}, total_seen=total_seen + ) + + +def _other_status_row(status: str) -> SimpleNamespace: + """failed/banned/cancelled — НЕ 'done', прерывает "нулевой" стрик (уже покрыт + _alert_if_consecutive_failures отдельно).""" + return SimpleNamespace(status=status, counters={"lots_fetched": 0}, total_seen=0) + + +class TestAlertIfConsecutiveZeroResults: + def test_no_alert_when_fewer_than_n_runs(self) -> None: + """(d) N-1 подряд done-с-нулём (< порога) → алерт НЕ шлётся.""" + db = _make_db([_zero_row() for _ in range(NZ - 1)]) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "cian") + mock_sentry.capture_message.assert_not_called() + + def test_alert_fires_exactly_at_n(self) -> None: + """(d) ровно N done-с-нулём подряд → алерт шлётся ровно один раз.""" + db = _make_db([_zero_row() for _ in range(NZ)]) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "cian") + mock_sentry.capture_message.assert_called_once() + (msg,) = mock_sentry.capture_message.call_args.args + assert "cian" in msg + assert str(NZ) in msg + + def test_no_alert_when_n_minus_1_plus_nonzero_done(self) -> None: + """N-1 done-с-нулём + один done с реальным результатом среди первых N → нет алерта.""" + rows = [_zero_row() for _ in range(NZ - 1)] + [_nonzero_row()] + db = _make_db(rows) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "yandex") + mock_sentry.capture_message.assert_not_called() + + def test_no_alert_when_streak_broken_by_failed_status(self) -> None: + """'failed' с total_seen=0 в первых N НЕ считается done-с-нулём → нет алерта + (тот класс уже покрыт _alert_if_consecutive_failures).""" + rows = [_zero_row(), _other_status_row("failed"), _zero_row()] + db = _make_db(rows) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "cian") + mock_sentry.capture_message.assert_not_called() + + def test_no_alert_when_n_plus_1_is_also_zero_done(self) -> None: + """N нулей newest + ещё один нулевой старше → анти-спам, алерт уже был бы + отправлен раньше.""" + db = _make_db([_zero_row() for _ in range(NZ + 1)]) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "cian") + mock_sentry.capture_message.assert_not_called() + + def test_alert_fires_when_n_plus_1_is_nonzero(self) -> None: + """N нулей newest + старше — реальный результат → стрик именно достиг N сейчас.""" + rows = [_zero_row() for _ in range(NZ)] + [_nonzero_row()] + db = _make_db(rows) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "cian") + mock_sentry.capture_message.assert_called_once() + + def test_sentry_exception_does_not_propagate(self) -> None: + db = _make_db([_zero_row() for _ in range(NZ)]) + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + mock_sentry.capture_message.side_effect = RuntimeError("sentry down") + _alert_if_consecutive_zero_results(db, "cian") # must not raise + + def test_db_query_exception_does_not_propagate(self) -> None: + db = MagicMock() + db.execute.side_effect = Exception("connection lost") + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + _alert_if_consecutive_zero_results(db, "cian") + mock_sentry.capture_message.assert_not_called() + + +def _make_db_for_mark_done(source: str, streak_rows: list[SimpleNamespace]) -> MagicMock: + """Mock DB for mark_done (#2625 zero-result alert hook). + + Sequence of execute() calls in mark_done: + 1. UPDATE scrape_runs SET status='done' ... RETURNING id → .first() + 2. (_alert_on_run_id) SELECT source FROM scrape_runs WHERE id=... → .fetchone() + 3. (_alert_if_consecutive_zero_results) SELECT status, counters ... → .fetchall() + """ + db = MagicMock() + + update_result = MagicMock() + update_result.first.return_value = SimpleNamespace(id=42) + + source_result = MagicMock() + source_result.fetchone.return_value = SimpleNamespace(source=source) + + streak_result = MagicMock() + streak_result.fetchall.return_value = streak_rows + + db.execute.side_effect = [update_result, source_result, streak_result] + return db + + +class TestMarkDoneZeroResultAlertIntegration: + """(d) mark_done wires _alert_if_consecutive_zero_results — red/green on N vs N-1.""" + + def test_mark_done_triggers_alert_on_nth_zero_streak(self) -> None: + rows = [_zero_row() for _ in range(NZ)] + db = _make_db_for_mark_done("cian", rows) + + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + mark_done(db, 42, {"lots_fetched": 0, "lots_inserted": 0}) + + mock_sentry.capture_message.assert_called_once() + (msg,) = mock_sentry.capture_message.call_args.args + assert "cian" in msg + + def test_mark_done_no_alert_below_threshold(self) -> None: + rows = [_zero_row() for _ in range(NZ - 1)] + db = _make_db_for_mark_done("cian", rows) + + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + mark_done(db, 42, {"lots_fetched": 0, "lots_inserted": 0}) + + mock_sentry.capture_message.assert_not_called() + + def test_mark_done_with_nonzero_result_does_not_alert(self) -> None: + """mark_done с реальным результатом обрывает стрик — свежая (newest) строка + не done-с-нулём → нет алерта, даже если предыдущие N-1 были нулевыми.""" + rows = [_nonzero_row(120)] + [_zero_row() for _ in range(NZ - 1)] + db = _make_db_for_mark_done("cian", rows) + + with patch("app.services.scrape_runs.sentry_sdk") as mock_sentry: + mark_done(db, 42, {"lots_fetched": 120, "lots_inserted": 8}) + + mock_sentry.capture_message.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_scrape_skip_visibility.py b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py new file mode 100644 index 00000000..0683f683 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scrape_skip_visibility.py @@ -0,0 +1,537 @@ +"""Пропуск расписания оставляет след — строку scrape_runs(status='skipped') (#2658). + +До #2658 планировщик пропускал наступившее окно НЕМО: logger + сдвиг next_run_at, ни +строки прогона, ни изменения last_run_at. cian_history_backfill так простоял 37 дней на +протухших куках Циана, и снаружи это выглядело как «всё по расписанию». + +Покрываем: + 1. mark_skipped — INSERT новой строки и схлопывание подряд идущих одинаковых причин. + 2. Все ПЯТЬ мест, которые раньше пропускали молча: kit `_claim_run` ×3 (already_running / + concurrent_claim / running_appeared_under_lock), kit `scheduler_loop` (unknown_source) + и продуктовый cian `pre_claim`. + 3. Достижимость алерта: протухшие куки (load_session → None) дают ERROR-запись, которая + в scraper-контейнере становится событием GlitchTip (event_level=ERROR), — раньше + алерт стоял во второй ветке, куда на протухших куках исполнение не доходит. + 4. Неломание монитора нулевых прогонов (#2625): 'skipped' — не «прогон вернул ноль + лотов», обе alert-выборки его не видят и стрик им не прерывается. + +Без сети, без БД. +""" + +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import runs as kit_runs +from scraper_kit.orchestration import scheduler as kit_sched +from scraper_kit.orchestration.scheduler import ( + SKIP_ALREADY_RUNNING, + SKIP_CONCURRENT_CLAIM, + SKIP_RUNNING_UNDER_LOCK, + SKIP_UNKNOWN_SOURCE, + SchedulerContext, + _claim_run, +) + + +def _make_sched(source: str) -> dict[str, Any]: + return { + "id": 1, + "source": source, + "enabled": True, + "window_start_hour": 2, + "window_end_hour": 5, + "default_params": {}, + "last_run_id": None, + "last_run_at": None, + "next_run_at": None, + } + + +# ── 1. mark_skipped: INSERT + схлопывание ──────────────────────────────────── + + +class _FakeSkipDB: + """Session-мок для mark_skipped: UPDATE-ветка (схлопывание) vs INSERT-ветка.""" + + def __init__(self, *, collapse: bool) -> None: + self._collapse = collapse + self.statements: list[tuple[str, dict[str, Any]]] = [] + self.committed = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) + self.statements.append((sql, params or {})) + result = MagicMock() + if "UPDATE scrape_runs r" in sql: + result.fetchone.return_value = MagicMock(id=7) if self._collapse else None + else: + result.fetchone.return_value = MagicMock(id=8) + return result + + def commit(self) -> None: + self.committed = True + + def sql_of(self, needle: str) -> tuple[str, dict[str, Any]] | None: + for sql, params in self.statements: + if needle in sql: + return sql, params + return None + + +def test_mark_skipped_inserts_row_with_machine_readable_reason() -> None: + db = _FakeSkipDB(collapse=False) + run_id = kit_runs.mark_skipped( + db, source="cian_history_backfill", reason="cian_cookies_expired", details="протухли" + ) + assert run_id == 8 + insert = db.sql_of("INSERT INTO scrape_runs") + assert insert is not None, "новая строка прогона не создана" + sql, params = insert + assert "'skipped'" in sql + assert params["source"] == "cian_history_backfill" + assert params["reason"] == "cian_cookies_expired" # слаг, не человеческий текст + assert "протухли" in params["counters"] + assert db.committed is True + + +def test_mark_skipped_collapses_consecutive_same_reason() -> None: + """Подряд идущие одинаковые пропуски не плодят строки — иначе тик 60с = строка/мин.""" + db = _FakeSkipDB(collapse=True) + run_id = kit_runs.mark_skipped(db, source="avito_full_load", reason=SKIP_ALREADY_RUNNING) + assert run_id == 7 + assert db.sql_of("INSERT INTO scrape_runs") is None, "схлопывание не сработало" + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, _params = update + assert "skips" in sql # счётчик повторов растёт вместо новой строки + + +def test_mark_skipped_collapse_refreshes_detail_and_started_at() -> None: + """Схлопывание освежает строку: detail, started_at (иначе живой стрик тонет в списке). + + Список прогонов сортирует ORDER BY started_at DESC с limit=20 — замороженный + started_at утопил бы 37-дневный стрик под свежими прогонами других источников. + Начало стрика сохраняется в counters.first_skip_at. + """ + db = _FakeSkipDB(collapse=True) + kit_runs.mark_skipped( + db, source="cian_history_backfill", reason="cian_cookies_expired", details="37 дн. назад" + ) + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, params = update + # clock_timestamp(), а не NOW(): отметки времени прогона пишутся настоящими + # часами, иначе внутри долгой открытой транзакции они замерзают (#2702). + assert "started_at = clock_timestamp()" in sql + assert "'detail', CAST(:details AS text)" in sql, "detail замерзает от первого пропуска" + assert "first_skip_at" in sql, "начало стрика потеряно" + assert params["details"] == "37 дн. назад" + + +def test_mark_skipped_latest_lookup_uses_indexed_order() -> None: + """Поиск последней строки идёт по (source, started_at DESC) — индекс из миграции 015. + + ORDER BY id DESC этот индекс не использует: для unknown_source (тик каждые 60 с + бессрочно) это был бы отбор всех строк источника с сортировкой раз в минуту. + """ + db = _FakeSkipDB(collapse=True) + kit_runs.mark_skipped(db, source="avito_full_load", reason=SKIP_ALREADY_RUNNING) + update = db.sql_of("UPDATE scrape_runs r") + assert update is not None + sql, _params = update + assert "ORDER BY started_at DESC, id DESC" in sql + + +# ── 2. пять мест: kit _claim_run ×3 ────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, *, scalar: Any = None, fetchone: Any = None) -> None: + self._scalar = scalar + self._fetchone = fetchone + + def scalar(self) -> Any: + return self._scalar + + def fetchone(self) -> Any: + return self._fetchone + + +class _FakeClaimDB: + """Мок Session для _claim_run (тот же сценарный контракт, что в parity-тестах).""" + + def __init__(self, *, running_states: list[bool], lock: bool = True) -> None: + self._running = list(running_states) + self._lock = lock + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt) + if "SELECT 1 FROM scrape_runs" in sql: + return _FakeResult(fetchone=(1,) if self._running.pop(0) else None) + if "pg_try_advisory_xact_lock" in sql: + return _FakeResult(scalar=self._lock) + return _FakeResult() + + def commit(self) -> None: + pass + + def rollback(self) -> None: + self.rolled_back = True + + +def _ctx() -> SchedulerContext: + runs = MagicMock() + runs.create_run = MagicMock(return_value=42) + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=runs, + ) + + +@pytest.mark.parametrize( + ("running_states", "lock", "expected_reason"), + [ + ([True], True, SKIP_ALREADY_RUNNING), + ([False], False, SKIP_CONCURRENT_CLAIM), + ([False, True], True, SKIP_RUNNING_UNDER_LOCK), + ], +) +def test_claim_run_skip_writes_skipped_row( + running_states: list[bool], lock: bool, expected_reason: str +) -> None: + """Каждая из трёх skip-веток _claim_run пишет строку прогона с своей причиной.""" + db = _FakeClaimDB(running_states=running_states, lock=lock) + with patch.object(kit_runs, "mark_skipped") as mark: + run_id = _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + + assert run_id is None + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == expected_reason + assert mark.call_args.kwargs["source"] == "avito_city_sweep" + + +def test_claim_run_under_lock_rolls_back_before_writing() -> None: + """Строка пишется ПОСЛЕ rollback'а — иначе её INSERT улетел бы в откат лока.""" + db = _FakeClaimDB(running_states=[False, True], lock=True) + order: list[str] = [] + real_rollback = db.rollback + + def _rollback() -> None: + order.append("rollback") + real_rollback() + + db.rollback = _rollback # type: ignore[method-assign] + with patch.object(kit_runs, "mark_skipped", side_effect=lambda *a, **k: order.append("skip")): + _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + + assert order == ["rollback", "skip"] + + +def test_claim_run_happy_path_writes_no_skip_row() -> None: + """Успешный claim не должен оставлять skip-строк (иначе счётчики мусорные).""" + db = _FakeClaimDB(running_states=[False, False], lock=True) + with patch.object(kit_runs, "mark_skipped") as mark: + run_id = _claim_run(db, _make_sched("avito_city_sweep"), _ctx()) + assert run_id == 42 + mark.assert_not_called() + + +# ── 2b. пятое место: scheduler_loop с неизвестным source ───────────────────── + + +async def test_scheduler_loop_unknown_source_writes_skipped_row() -> None: + """enabled-расписание без handler'а: раньше — warning каждый тик и ноль следов.""" + ctx = SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(return_value=MagicMock()), + runs=MagicMock(), + # первый вызов (верх тика) — работаем, дальше — drain, чтобы выйти из while True + shutdown_requested=MagicMock(side_effect=[False, True, True]), + ) + with ( + patch.object(kit_sched.asyncio, "sleep", AsyncMock()), + patch.object(kit_sched, "reap_zombies", MagicMock(return_value=0)), + patch.object( + kit_sched, + "get_due_schedules", + MagicMock(return_value=[_make_sched("source_from_mars")]), + ), + patch.object(kit_runs, "mark_skipped") as mark, + ): + await kit_sched.scheduler_loop(ctx, registry={}) + + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == SKIP_UNKNOWN_SOURCE + assert mark.call_args.kwargs["source"] == "source_from_mars" + + +# ── 3. cian pre_claim: строка + достижимый алерт ───────────────────────────── + + +def _patch_cian( + *, + cookies: dict[str, str] | None, + expires_at: datetime | None, + verify: Any = None, +) -> Any: + from app.services import cian_session + + return ( + patch.object(cian_session, "load_session", MagicMock(return_value=cookies)), + patch.object(cian_session, "session_expires_at", MagicMock(return_value=expires_at)), + patch.object(cian_session, "verify_session", AsyncMock(return_value=verify)), + ) + + +async def test_cian_pre_claim_expired_cookies_logs_error() -> None: + """Фальсификация #2658-2: на протухших куках ДОЛЖНА быть ERROR-запись. + + Именно ERROR — в scraper-контейнере GlitchTip поднят с + LoggingIntegration(event_level=ERROR) (scheduler_main.py), warning событием не станет. + Старый код в этой ветке писал только logger.warning, а capture_message стоял во + второй ветке (verify_session → None), недостижимой при протухании: load_session сам + фильтрует expires_at_estimate > NOW(). + """ + from app.services import product_handlers + + expired = datetime.now(tz=UTC) - timedelta(days=37) + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=expired) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()), + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is False + errors = [r for r in records if r.levelno >= logging.ERROR] + assert errors, "протухание кук не породило ERROR-запись → события GlitchTip не будет" + + +async def test_cian_pre_claim_expired_cookies_writes_skipped_row() -> None: + """Немой `return False` заменён строкой прогона с причиной cian_cookies_expired.""" + from app.services import product_handlers + + expired = datetime.now(tz=UTC) - timedelta(days=37) + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=expired) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()) as defer, + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert proceed is False + mark.assert_called_once() + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_EXPIRED + assert mark.call_args.kwargs["source"] == "cian_history_backfill" + defer.assert_called_once() # next_run_at по-прежнему двигаем (не долбим Циан каждые 60с) + + +async def test_cian_pre_claim_missing_cookies_reason_differs_from_expired() -> None: + """«Кук нет вовсе» и «протухли» — разные слаги: причина машиночитаема.""" + from app.services import product_handlers + + p_load, p_exp, p_verify = _patch_cian(cookies=None, expires_at=None) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_MISSING + assert product_handlers.SKIP_CIAN_COOKIES_MISSING != product_handlers.SKIP_CIAN_COOKIES_EXPIRED + + +async def test_cian_pre_claim_rejected_cookies_writes_skipped_row() -> None: + """Вторая ветка (Циан не принимает куки) — тоже строка, а не только алерт.""" + from app.services import product_handlers + + future = datetime.now(tz=UTC) + timedelta(days=20) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=future, verify=None + ) + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers, "kit_defer_next_run_at", MagicMock()), + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + + assert proceed is False + assert mark.call_args.kwargs["reason"] == product_handlers.SKIP_CIAN_COOKIES_INVALID + + +async def test_cian_pre_claim_warns_before_expiry_not_after() -> None: + """Предупреждаем ЗАРАНЕЕ: куки ещё рабочие, но жить им меньше COOKIE_EXPIRY_WARN_DAYS. + + Обновление кук — ручная операция; алерт по факту протухания приходит, когда сбор уже + встал. Гейт при этом пропускает прогон (proceed=True) — предупреждение, не блокировка. + """ + from app.services import cian_session, product_handlers + + soon = datetime.now(tz=UTC) + timedelta(days=cian_session.COOKIE_EXPIRY_WARN_DAYS - 1) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=soon, verify={"user": {"isAuthenticated": True}} + ) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with p_load, p_exp, p_verify: + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + # Срок считаем по ЗАПИСИ, которую взял load_session: при нескольких аккаунтах + # свежайшая-любая может быть чужой протухшей строкой (валидности она не знает). + assert cian_session.session_expires_at.call_args.kwargs == {"valid_only": True} + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is True + assert [r for r in records if r.levelno >= logging.ERROR], "не предупредили заранее" + + +async def test_cian_pre_claim_fresh_cookies_are_silent() -> None: + """Свежие куки — ни алерта, ни skip-строки (иначе алерт-усталость).""" + from app.services import product_handlers + + far = datetime.now(tz=UTC) + timedelta(days=25) + p_load, p_exp, p_verify = _patch_cian( + cookies={"DMIR_AUTH": "x"}, expires_at=far, verify={"user": {"isAuthenticated": True}} + ) + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Collector() + product_handlers.logger.addHandler(handler) + try: + with ( + p_load, + p_exp, + p_verify, + patch.object(product_handlers.kit_runs, "mark_skipped", MagicMock()) as mark, + ): + proceed = await product_handlers._cian_pre_claim( + MagicMock(), _make_sched("cian_history_backfill"), MagicMock() + ) + finally: + product_handlers.logger.removeHandler(handler) + + assert proceed is True + mark.assert_not_called() + assert not [r for r in records if r.levelno >= logging.ERROR] + + +# ── 4. монитор нулевых прогонов не смешивается с пропусками ────────────────── + + +class _RecordingDB: + """Возвращает заданные строки на SELECT и запоминает SQL (для проверки фильтров).""" + + def __init__(self, rows: list[Any]) -> None: + self.rows = rows + self.sql: list[str] = [] + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + self.sql.append(str(stmt)) + result = MagicMock() + result.fetchall.return_value = self.rows + return result + + +def test_zero_result_monitor_ignores_skipped_rows() -> None: + """'skipped' НЕ участвует в стрике нулевых прогонов — это не «прогон вернул ноль». + + Смешать их в одном счётчике нельзя в обе стороны: пропуск не должен ни считаться + нулевым прогоном, ни прерывать стрик реальных нулевых. Отсечка делается в SQL — + проверяем, что 'skipped' не попал в список статусов выборки. + """ + db = _RecordingDB(rows=[]) + kit_runs._alert_if_consecutive_zero_results(db, "cian_city_sweep") + kit_runs._alert_if_consecutive_failures(db, "cian_city_sweep") + + assert db.sql, "alert-выборка не выполнилась" + for sql in db.sql: + assert "status IN ('failed', 'banned', 'done', 'cancelled')" in sql + assert "skipped" not in sql + + +def test_admin_runs_filter_accepts_skipped_status() -> None: + """GET /admin/scrape/runs?status=skipped не должен отдавать 422. + + Единственная поверхность, где оператор спрашивает «что сейчас пропускается» — фильтр + статуса в таблице прогонов. Без 'skipped' в Literal строки видны, а вопрос задать + нельзя, то есть цель #2658 на UI достигнута наполовину. + """ + import typing + + from app.api.v1.admin import list_scrape_runs_unified + + annotation = typing.get_type_hints(list_scrape_runs_unified, include_extras=True)["status"] + allowed: set[str] = set() + for arg in typing.get_args(typing.get_args(annotation)[0]): + allowed.update(typing.get_args(arg)) + assert "skipped" in allowed + + +def test_mark_skipped_status_is_not_a_failure_status() -> None: + """Строка-пропуск не попадает и в failed/banned-стрик (алерт «3 подряд ошибки»).""" + db = _FakeSkipDB(collapse=False) + kit_runs.mark_skipped(db, source="cian_history_backfill", reason="cian_cookies_expired") + insert = db.sql_of("INSERT INTO scrape_runs") + assert insert is not None + sql, _params = insert + assert "'skipped'" in sql + assert "'failed'" not in sql and "'banned'" not in sql diff --git a/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py b/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py index 105f9e78..5cf258a0 100644 --- a/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py +++ b/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py @@ -70,6 +70,8 @@ def test_scraper_config_satisfies_protocol() -> None: assert isinstance(config.use_proxy_pool_curl, bool) # #2164 P4: флаг browser-пула присутствует и bool. assert isinstance(config.use_proxy_pool_browser, bool) + # #2616 шаг 1: признак окружения присутствует и строка (проксирует settings.environment). + assert isinstance(config.environment, str) def test_proxy_provider_satisfies_protocol() -> None: @@ -79,6 +81,11 @@ def test_proxy_provider_satisfies_protocol() -> None: assert callable(provider.acquire) assert callable(provider.release) assert callable(provider.mark_health) + # #2164 sticky-session fix (2026-08): touch() heartbeat — продлевает lease для + # многочасовых browser-сессий (см. scraper_kit.browser_fetcher). + assert callable(provider.touch) + # #2600 п.1: mark_banned() — довести сигнал бана площадкой до пула. + assert callable(provider.mark_banned) def test_session_factory_satisfies_protocol() -> None: diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ecfd4578..1a2db7e6 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -1,9 +1,11 @@ """Offline tests для unified scrapers-страницы admin-API (epic консолидации UI). -Покрытие 3 эндпоинтов (db/httpx мокаются, NO live network/DB): +Покрытие эндпоинтов (db/httpx мокаются, NO live network/DB): - GET /api/v1/admin/scrape/runs — unified runs + total + фильтры - GET /api/v1/admin/scraper/health — fetch_mode + browser + providers - - POST /api/v1/admin/scraper/{src}/rotate-ip — changeip + no-rotate-url кейс + +POST /scraper/{src}/rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая +подписка (#2613); её тесты удалены вместе с ручкой. """ from __future__ import annotations @@ -40,12 +42,15 @@ def client() -> TestClient: def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: - """list_all → total + rows; ISO-форматирование дат + error_text/run_type.""" + """list_all → total + rows; ISO-форматирование дат + error_text. + + run_type из выдачи убран (#2674): вырожденная колонка (одно значение во всех + 3244 прогонах), вид прогона называет source. + """ fake_rows = [ { "run_id": 5, "source": "avito_city_sweep", - "run_type": "city_sweep", "status": "done", "params": {"pages_per_anchor": 3}, "counters": {"lots_fetched": 120}, @@ -66,7 +71,7 @@ def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: row = body["rows"][0] assert row["run_id"] == 5 assert row["source"] == "avito_city_sweep" - assert row["run_type"] == "city_sweep" + assert "run_type" not in row assert row["total_seen"] == 120 assert row["new_count"] == 30 assert row["error_text"] is None @@ -147,13 +152,11 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None: "cian": None, "yandex": "http://u:p@host.y:10051", } - rotate_urls = {"avito": "http://ch/changeip", "cian": None, "yandex": None} with ( patch.object(admin_module, "_probe_browser_health", fake_browser_health), patch.object(admin_module, "_probe_current_ip", fake_current_ip), patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]), - patch.object(admin_module, "_provider_rotate_url", lambda s: rotate_urls[s]), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), ): r = client.get("/api/v1/admin/scraper/health") @@ -169,13 +172,14 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None: assert set(by_source) == {"avito", "cian", "yandex"} assert by_source["avito"]["proxy_host"] == "host.a" assert by_source["avito"]["proxy_port"] == 10049 - assert by_source["avito"]["rotate_supported"] is True + # #2616 шаг 2: changeip mobileproxy-ротация снята — rotate_supported всегда + # False (живая ASocks-ротация — /admin/proxies/{id}/rotate, не per-source). + assert by_source["avito"]["rotate_supported"] is False assert by_source["avito"]["current_ip"] == "1.2.3.4" - # cian без proxy → host/port/ip null, rotate_supported False + # cian без proxy → host/port/ip null assert by_source["cian"]["proxy_host"] is None assert by_source["cian"]["current_ip"] is None assert by_source["cian"]["rotate_supported"] is False - # yandex имеет proxy но нет rotate_url assert by_source["yandex"]["proxy_host"] == "host.y" assert by_source["yandex"]["rotate_supported"] is False @@ -204,7 +208,6 @@ def test_health_browser_unreachable(client: TestClient) -> None: patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module, "_probe_current_ip", fake_current_ip), patch.object(admin_module, "_provider_proxy_url", lambda s: None), - patch.object(admin_module, "_provider_rotate_url", lambda s: None), ): r = client.get("/api/v1/admin/scraper/health") @@ -214,137 +217,6 @@ def test_health_browser_unreachable(client: TestClient) -> None: assert body["browser"]["browsers"] == {} -# ── API 3: POST /scraper/{source}/rotate-ip ────────────────────────────────── - - -def test_rotate_ip_calls_changeip(client: TestClient) -> None: - """rotate_url задан → GET changeip + format=json, возврат new_ip из ответа.""" - from app.api.v1 import admin as admin_module - - captured: dict[str, str] = {} - - class _FakeResp: - def raise_for_status(self) -> None: - return None - - def json(self) -> dict[str, str]: - return {"new_ip": "9.9.9.9"} - - class _FakeClient: - def __init__(self, *a: Any, **k: Any) -> None: - pass - - async def __aenter__(self) -> _FakeClient: - return self - - async def __aexit__(self, *a: Any) -> None: - return None - - async def get(self, url: str, *a: Any, **k: Any) -> _FakeResp: - captured["url"] = url - return _FakeResp() - - with ( - patch.object(admin_module.httpx, "AsyncClient", _FakeClient), - patch.object(admin_module.settings, "avito_proxy_rotate_url", "http://ch/changeip?key=x"), - ): - r = client.post("/api/v1/admin/scraper/avito/rotate-ip") - - assert r.status_code == 200 - body = r.json() - assert body["ok"] is True - assert body["new_ip"] == "9.9.9.9" - assert "format=json" in captured["url"] - # уже был '?' в URL → должен использовать '&' - assert captured["url"].endswith("&format=json") - - -def test_rotate_ip_no_rotate_url(client: TestClient) -> None: - """rotate_url не задан → ok=False + reason про auto-rotate, changeip НЕ дёргается.""" - from app.api.v1 import admin as admin_module - - with patch.object(admin_module.settings, "cian_proxy_rotate_url", None): - r = client.post("/api/v1/admin/scraper/cian/rotate-ip") - - assert r.status_code == 200 - body = r.json() - assert body["ok"] is False - assert body["new_ip"] is None - assert "auto-rotate" in body["reason"] - - -def test_rotate_ip_invalid_source_422(client: TestClient) -> None: - """source вне {avito,cian,yandex} → 422 (Literal-валидация path).""" - r = client.post("/api/v1/admin/scraper/domclick/rotate-ip") - assert r.status_code == 422 - - -def test_rotate_ip_changeip_error(client: TestClient) -> None: - """httpx-ошибка changeip → ok=False + reason.""" - from app.api.v1 import admin as admin_module - - class _BoomClient: - def __init__(self, *a: Any, **k: Any) -> None: - pass - - async def __aenter__(self) -> _BoomClient: - return self - - async def __aexit__(self, *a: Any) -> None: - return None - - async def get(self, *a: Any, **k: Any) -> Any: - raise RuntimeError("changeip 500") - - with ( - patch.object(admin_module.httpx, "AsyncClient", _BoomClient), - patch.object(admin_module.settings, "yandex_proxy_rotate_url", "http://ch/changeip"), - ): - r = client.post("/api/v1/admin/scraper/yandex/rotate-ip") - - assert r.status_code == 200 - body = r.json() - assert body["ok"] is False - # security-audit: нейтральный reason, БЕЗ текста исходного исключения - # (str(exc) httpx мог нести rotate_url с proxy-ключом в query — см. тест ниже). - assert body["reason"] == "changeip request failed" - - -def test_rotate_ip_changeip_error_does_not_leak_proxy_key(client: TestClient) -> None: - """security-audit: секретный API-ключ провайдера в rotate_url НЕ должен попасть - в HTTP-ответ клиенту через текст httpx-исключения (раньше - `reason=f"changeip error: {exc}"` отдавал str(exc) с полным URL, включая - query-параметр ключа, наружу).""" - from app.api.v1 import admin as admin_module - - secret_url = "http://ch/changeip?proxy_key=TOP-SECRET-KEY-1234" - - class _BoomClient: - def __init__(self, *a: Any, **k: Any) -> None: - pass - - async def __aenter__(self) -> _BoomClient: - return self - - async def __aexit__(self, *a: Any) -> None: - return None - - async def get(self, *a: Any, **k: Any) -> Any: - raise RuntimeError(f"All connection attempts failed for {secret_url}&format=json") - - with ( - patch.object(admin_module.httpx, "AsyncClient", _BoomClient), - patch.object(admin_module.settings, "avito_proxy_rotate_url", secret_url), - ): - r = client.post("/api/v1/admin/scraper/avito/rotate-ip") - - assert r.status_code == 200 - assert "TOP-SECRET-KEY-1234" not in r.text - body = r.json() - assert body["ok"] is False - assert "TOP-SECRET-KEY-1234" not in (body["reason"] or "") - - # ── API 4: GET /scraper/pacing ─────────────────────────────────────────────── @@ -488,10 +360,12 @@ def _make_dq_db_mock() -> MagicMock: """Мок db-сессии для GET /scraper/data-quality.""" db = MagicMock() - # listings rows: avito=1000 active, cian=500 active + # listings rows: avito=1000 active (все свежие), cian=500 active / 300 протухших + # (#2660: stale_count идёт рядом с active_count — «активно» ≠ «живо»). avito_row = { "source": "avito", "active_count": 1000, + "stale_count": 0, "f_description": 900, "f_photo_urls": 980, "f_address": 1000, @@ -506,6 +380,7 @@ def _make_dq_db_mock() -> MagicMock: cian_row = { "source": "cian", "active_count": 500, + "stale_count": 300, "f_description": 450, "f_photo_urls": 490, "f_address": 500, @@ -566,6 +441,7 @@ def test_data_quality_shape(client: TestClient) -> None: avito = sources["avito"] assert avito["active_count"] == 1000 + assert avito["stale_count"] == 0 # #2660 fields = avito["fields"] assert 0.0 <= fields["description"] <= 100.0 assert fields["description"] == pytest.approx(90.0, abs=0.1) @@ -574,6 +450,7 @@ def test_data_quality_shape(client: TestClient) -> None: cian = sources["cian"] assert cian["active_count"] == 500 + assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1) assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py index fc51ffd6..7084968b 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py @@ -141,13 +141,19 @@ async def test_avito_fetch_detail_with_config_reconnects_on_403_like_legacy() -> # ── AvitoScraper: kit constructor now requires ScraperConfig (positional) ──────── -def test_avito_scraper_rotate_ip_reads_injected_config() -> None: +def test_avito_scraper_reads_injected_config() -> None: """kit AvitoScraper(config) is a required positional arg (legacy AvitoScraper() - took none, reading settings.avito_proxy_rotate_url directly at call time inside - _rotate_ip). avito_detail_backfill.py now constructs AvitoScraper(RealScraperConfig()) - — this proves the injected config is what _rotate_ip actually reads.""" + took none, reading settings.* directly at call time). avito_detail_backfill.py + now constructs AvitoScraper(RealScraperConfig()) — this proves the injected + config (not the settings singleton) is what the scraper actually reads. + + #2616 шаг 2: previously asserted on avito_proxy_rotate_url (changeip mobileproxy, + removed — dead account #2613, _rotate_ip is now a no-op stub reading no config). + scraper_proxy_url (still config-injected, drives _build_cffi_session) proves the + same DI invariant. + """ scraper = KitAvitoScraper(RealScraperConfig()) - assert scraper._config.avito_proxy_rotate_url == settings.avito_proxy_rotate_url + assert scraper._config.scraper_proxy_url == settings.scraper_proxy_url # ── BrowserFetcher(endpoint=) wiring — house_imv_backfill.py / cian_history_backfill.py ── @@ -182,7 +188,13 @@ async def test_house_imv_backfill_browser_fetcher_uses_settings_endpoint() -> No ] await house_imv_backfill.backfill_house_imv(db, batch_size=1) - assert captured == {"source": "avito", "endpoint": settings.browser_http_endpoint} + # #2698: к endpoint= добавилась проводка пула — без неё сайдкар брал env-прокси + # (узел с affinity='domclick'), и домовая оценка падала 34 дня подряд. + assert captured["source"] == "avito" + assert captured["endpoint"] == settings.browser_http_endpoint + assert captured["proxy_provider"] is not None + assert captured["use_pool"] == settings.use_proxy_pool_browser + assert captured["environment"] == settings.environment async def test_cian_history_backfill_browser_fetcher_uses_settings_endpoint() -> None: diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index 0c5b2554..f0615237 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -43,6 +43,7 @@ class _RunsRecorder: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] + self.ban_kinds: list[str] = [] def is_cancelled(self, db: Any, run_id: int) -> bool: return False @@ -53,7 +54,16 @@ class _RunsRecorder: def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.calls.append(("mark_done", dict(counters))) - def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + def mark_banned( + self, + db: Any, + run_id: int, + error: str, + counters: dict[str, Any], + *, + ban_kind: str = "platform", + ) -> None: + self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.calls.append(("mark_banned", dict(counters))) def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: @@ -93,6 +103,7 @@ class _Scenario: avito_serp_ok_not_banned: bool = True, avito_proxy_max_rotations: int = 0, lots_have_house_url: bool = False, + city_slug: str | None = None, ) -> None: self.anchors = anchors self.per_anchor = per_anchor @@ -105,21 +116,21 @@ class _Scenario: self.avito_serp_ok_not_banned = avito_serp_ok_not_banned self.avito_proxy_max_rotations = avito_proxy_max_rotations self.lots_have_house_url = lots_have_house_url + # #2594: city_slug развёртки — прокидывается в run_avito_city_sweep(city_slug=...) + # для проверки, что save_listings получает правильный city=... из контекста. + self.city_slug = city_slug def _config(self) -> SimpleNamespace: return SimpleNamespace( scraper_fetch_mode="curl_cffi", browser_http_endpoint="http://browser.test/fetch", scraper_proxy_url=None, - avito_proxy_rotate_url=None, avito_proxy_max_rotations=self.avito_proxy_max_rotations, avito_serp_ok_not_banned=self.avito_serp_ok_not_banned, avito_proxy_rotate_settle_s=0.0, proxy_rotate_attempts=1, proxy_rotate_attempt_timeout_s=1.0, - cian_proxy_rotate_url=None, cian_proxy_max_rotations=0, - yandex_proxy_rotate_url=None, yandex_proxy_max_rotations=0, scraper_skip_seen_today=False, ) @@ -170,11 +181,25 @@ def _async_session_cm() -> MagicMock: return sess -async def _drive(scenario: _Scenario) -> _DriveResult: +async def _drive( + scenario: _Scenario, + *, + capture: dict[str, Any] | None = None, + proxy_provider: Any = None, +) -> _DriveResult: + """capture: опциональный dict — если передан, кладём туда save_mock (#2594) и + avito_scraper_cls (#2616, MagicMock class — для инспекции AvitoScraper(...) call_args, + напр. proxy_provider=) для инспекции call_args (city=...) без изменения возвращаемого + _DriveResult (backward-compat для всех существующих вызовов _drive без capture). + proxy_provider: прокидывается в run_avito_city_sweep(...) как есть (#2616 wiring test).""" recorder = _RunsRecorder() db = _make_db(scenario) scraper = _make_scraper(scenario, AvitoBlockedError) save_mock = MagicMock(side_effect=scenario._save_side_effects()) + avito_scraper_cls = MagicMock(return_value=scraper) + if capture is not None: + capture["save_mock"] = save_mock + capture["avito_scraper_cls"] = avito_scraper_cls imv_res = None if scenario.imv_result is not None: @@ -186,7 +211,7 @@ async def _drive(scenario: _Scenario) -> _DriveResult: pfx = "scraper_kit.orchestration.pipeline" with ( - patch(f"{pfx}.AvitoScraper", return_value=scraper), + patch(f"{pfx}.AvitoScraper", avito_scraper_cls), patch(f"{pfx}.save_listings", save_mock), patch(f"{pfx}.fetch_house_catalog", AsyncMock(return_value=MagicMock())), patch(f"{pfx}.save_house_catalog_enrichment", return_value={"house_id": 1}), @@ -206,8 +231,10 @@ async def _drive(scenario: _Scenario) -> _DriveResult: matcher=MagicMock(), enrichment=enrichment, shutdown_requested=lambda: False, + proxy_provider=proxy_provider, radius_m=1000, anchors=scenario.anchors, + city_slug=scenario.city_slug, pages_per_anchor=1, enrich_houses=scenario.enrich_houses, detail_top_n=scenario.detail_top_n, @@ -307,3 +334,151 @@ async def test_imv_phase_counters() -> None: assert counters["imv_attempted"] == 3 assert counters["imv_enriched"] == 2 assert counters["imv_failed"] == 1 + + +# ── #2594: listings.city проставляется из контекста развёртки ──────────────── +# +# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не +# писала — адрес без города в тексте ("ул. Победы, 30") при геокодинге считался +# «город не назван» и коллизировал с одноимённой ЕКБ-улицей. Тесты проверяют, что +# save_listings() теперь получает правильный city= для обоих случаев: явный +# oblast-город (city_slug задан) И EKB-развёртка той же функции (city_slug=None — +# симметрия, а не «не знаем город»). + + +@pytest.mark.asyncio +async def test_city_stamped_from_city_slug() -> None: + """city_slug='nizhniy_tagil' → save_listings(..., city='Нижний Тагил').""" + scenario = _Scenario( + anchors=[(56.84, 60.60, "A1")], + per_anchor=[("lots", 3, 3, 0)], + city_slug="nizhniy_tagil", + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city"] == "Нижний Тагил" + + +@pytest.mark.asyncio +async def test_city_defaults_to_ekaterinburg_when_no_city_slug() -> None: + """city_slug=None (ЕКБ-развёртка той же run_avito_city_sweep) → + save_listings(..., city='Екатеринбург') — симметрия с oblast-городами (#2594), + а не оставленный NULL.""" + scenario = _Scenario( + anchors=[(56.84, 60.60, "A1")], + per_anchor=[("lots", 3, 3, 0)], + city_slug=None, + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city"] == "Екатеринбург" + + +# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ── +# +# Замер на проде (см. PR): city_slug="verkhnyaya_pyshma" развёртка стамповала +# 'Верхняя Пышма' на лоты, физически лежащие в ЕКБ. save_listings режет city +# per-lot, если получит city_anchor/city_radius_km — оркестратор обязан их передать +# для oblast-города и НЕ передавать (None/None) для ЕКБ (нет большего соседа). + + +@pytest.mark.asyncio +async def test_avito_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None: + """city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km + из pipeline.get_city_anchor_point/get_city_stamp_radius_km (НЕ None/None).""" + from scraper_kit.orchestration.pipeline import ( + get_city_anchor_point, + get_city_stamp_radius_km, + ) + + scenario = _Scenario( + anchors=[(56.976, 60.578, "В.Пышма центр")], + per_anchor=[("lots", 3, 3, 0)], + city_slug="verkhnyaya_pyshma", + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma") + assert save_mock.call_args.kwargs["city_radius_km"] == get_city_stamp_radius_km( + "verkhnyaya_pyshma" + ) + + +@pytest.mark.asyncio +async def test_avito_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None: + """city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None + — guard остаётся выключенным (нет города крупнее ЕКБ, ЕКБ-развёртка не должна + ломаться геопроверкой).""" + scenario = _Scenario( + anchors=[(56.84, 60.60, "A1")], + per_anchor=[("lots", 3, 3, 0)], + city_slug=None, + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city_anchor"] is None + assert save_mock.call_args.kwargs["city_radius_km"] is None + + +# ── #2616: run_avito_city_sweep прокидывает proxy_provider в AvitoScraper(...) ── +# +# NOT load-bearing здесь (в отличие от run_avito_full_load): browser_mode переопределяет +# scraper._browser напрямую shared_bf'ом (уже построенным с proxy_provider=proxy_provider +# ВЫШЕ по стеку, до конструктора AvitoScraper) — __aenter__ вообще не вызывается для +# per-anchor scraper'а. Это регрессионный замок консистентности с cian/yandex-паттерном, +# на случай будущего рефакторинга, который начнёт полагаться на __aenter__. + + +@pytest.mark.asyncio +async def test_avito_city_sweep_passes_proxy_provider_to_scraper_constructor() -> None: + """proxy_provider=X → AvitoScraper(config, target_city_slug=..., proxy_provider=X). + + Falsification: если pipeline.py перестанет прокидывать proxy_provider в конструктор + AvitoScraper внутри run_avito_city_sweep, avito_scraper_cls.call_args.kwargs не будет + содержать sentinel — assert падает на VALUE, не на TypeError (MagicMock не проверяет + сигнатуру). + """ + sentinel = object() + scenario = _Scenario( + anchors=[(56.84, 60.60, "A1")], + per_anchor=[("lots", 1, 1, 0)], + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture, proxy_provider=sentinel) + avito_scraper_cls = capture["avito_scraper_cls"] + avito_scraper_cls.assert_called_once() + assert avito_scraper_cls.call_args.kwargs.get("proxy_provider") is sentinel + + +# ── #2701: снимок обязан знать свой прогон ──────────────────────────────────── +# +# Замер на проде до правки: listings_snapshots 396 162 строки, run_id заполнен у +# 245 647 (62.0%); по avito 80.3%, и у ВСЕХ дневных city-sweep строк (напр. 944 за +# 2026-08-06, 635 за 2026-08-05) run_id пуст — sweep его просто не передавал, хотя +# держал в своей же сигнатуре и логировал в каждой строке. +# +# Вторая половина цепочки (save_listings прокидывает run_id в upsert_listing_snapshot) +# уже под замком: test_snapshot_writer.py::test_save_listings_snapshot_receives_run_id. + + +@pytest.mark.asyncio +async def test_avito_city_sweep_passes_run_id_to_save_listings() -> None: + """run_avito_city_sweep(run_id=1) → save_listings(..., run_id=1) → снимок с прогоном. + + Falsification: убрать `run_id=run_id` из вызова save_listings в pipeline.py — + kwargs не содержит run_id, assert падает. + """ + scenario = _Scenario( + anchors=_ANCHORS_2, + per_anchor=[("lots", 3, 3, 0), ("lots", 2, 2, 0)], + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_count == 2, "оба anchor'а сохраняют — проверяем оба вызова" + for call in save_mock.call_args_list: + assert call.kwargs.get("run_id") == 1, "снимок anchor'а остался бы без прогона" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 0c8bbd9f..0c1dc4c4 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -9,7 +9,7 @@ sweep). Здесь — остальные 7 sweep'ов (#2135 F2): City sweep'ы (приоритет — активны в проде): - run_yandex_city_sweep — combos SERP + save + price-history - run_cian_city_sweep — SERP + newbuilding_only-фильтр + save - - run_domclick_city_sweep — BFF citywide + честный статус (done / failed) + - run_domclick_city_sweep — BFF citywide + честный статус (done / failed / banned) Плюс: - run_avito_newbuilding_sweep — citywide novostroyka SERP + save Full load'ы (smoke — импорт + базовый прогон через on_bucket): @@ -39,6 +39,7 @@ from scraper_kit.orchestration.pipeline import ( run_yandex_city_sweep, run_yandex_full_load, ) +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS PFX = "scraper_kit.orchestration.pipeline" @@ -52,6 +53,7 @@ class _RunsRecorder: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] + self.ban_kinds: list[str] = [] def is_cancelled(self, db: Any, run_id: int) -> bool: return False @@ -62,7 +64,16 @@ class _RunsRecorder: def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.calls.append(("mark_done", dict(counters))) - def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + def mark_banned( + self, + db: Any, + run_id: int, + error: str, + counters: dict[str, Any], + *, + ban_kind: str = "platform", + ) -> None: + self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.calls.append(("mark_banned", dict(counters))) def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: @@ -83,15 +94,12 @@ def _config() -> SimpleNamespace: scraper_fetch_mode="curl_cffi", browser_http_endpoint="http://browser.test/fetch", scraper_proxy_url=None, - avito_proxy_rotate_url=None, avito_proxy_max_rotations=0, avito_serp_ok_not_banned=True, avito_proxy_rotate_settle_s=0.0, proxy_rotate_attempts=1, proxy_rotate_attempt_timeout_s=1.0, - cian_proxy_rotate_url=None, cian_proxy_max_rotations=0, - yandex_proxy_rotate_url=None, yandex_proxy_max_rotations=0, scraper_skip_seen_today=False, cian_full_load_per_fetch_timeout_s=0.0, @@ -107,6 +115,14 @@ def _ctx_scraper(**attrs: Any) -> MagicMock: m = MagicMock() m.__aenter__ = AsyncMock(return_value=m) m.__aexit__ = AsyncMock(return_value=None) + # #2625: extraction-attempt counters (Cian state_extraction_*, Yandex + # gate_fetch_*) default to 0 — honest-empty / no captcha signal — so fixtures + # that don't care about the banned-detect stay green. Override via kwargs to + # test the detect-rule itself (see test_*_all_extraction_failed_marks_banned). + m.state_extraction_attempts = 0 + m.state_extraction_failures = 0 + m.gate_fetch_attempts = 0 + m.gate_fetch_failures = 0 for k, v in attrs.items(): setattr(m, k, v) return m @@ -136,7 +152,10 @@ def _yandex_scraper(combos: list[tuple[str, list[Any]]]) -> MagicMock: return _ctx_scraper(fetch_around_multi_room=_fetch) -async def _drive_yandex_city() -> _DriveResult: +async def _drive_yandex_city( + *, city_slug: str | None = None, capture: dict[str, Any] | None = None +) -> _DriveResult: + """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() db = MagicMock() combos = [ @@ -145,6 +164,8 @@ async def _drive_yandex_city() -> _DriveResult: ] scraper = _yandex_scraper(combos) save_mock = MagicMock(side_effect=[(2, 0), (1, 0)]) + if capture is not None: + capture["save_mock"] = save_mock cfg = _config() enrichment = MagicMock() enrichment.record_yandex_price_history = MagicMock(return_value=5) @@ -160,6 +181,7 @@ async def _drive_yandex_city() -> _DriveResult: enrichment=enrichment, run_id=1, anchors=None, + city_slug=city_slug, pages_per_anchor=1, request_delay_sec=0.0, enrich_address=False, @@ -178,6 +200,79 @@ async def test_yandex_city_sweep() -> None: assert calls[-1][0] == "mark_done" +# ── #2625: captcha/blocked-vs-empty detect (Yandex gate-API) ────────────────── + + +async def _drive_yandex_city_scrapers(scrapers: list[MagicMock]) -> _DriveResult: + """Как _drive_yandex_city, но с N явными anchor'ами → N свежих scraper'ов.""" + recorder = _RunsRecorder() + db = MagicMock() + anchors = [(56.84 + i * 0.01, 60.60, f"A{i}") for i in range(len(scrapers))] + save_mock = MagicMock(return_value=(0, 0)) + cfg = _config() + enrichment = MagicMock() + enrichment.record_yandex_price_history = MagicMock(return_value=0) + with ( + patch(f"{PFX}.YandexRealtyScraper", side_effect=scrapers), + patch(f"{PFX}.save_listings", save_mock), + patch(f"{PFX}.runs", recorder), + ): + counters = await run_yandex_city_sweep( + db, + config=cfg, + matcher=MagicMock(), + enrichment=enrichment, + run_id=1, + anchors=anchors, + pages_per_anchor=1, + request_delay_sec=0.0, + enrich_address=False, + ) + return counters.to_dict(), _normalize(recorder.calls) + + +def _yandex_empty_scraper(*, attempts: int, failures: int) -> MagicMock: + """Yandex scraper fixture: fetch_around_multi_room не сохраняет лоты (on_combo не + вызывается), но incurs attempts/failures gate-fetch counters — как капча/тарпит + (failures==attempts) или честная пустая выдача (failures=0).""" + + async def _fetch(*_a: Any, on_combo: Any = None, **_k: Any) -> None: + return None + + return _ctx_scraper( + fetch_around_multi_room=_fetch, + gate_fetch_attempts=attempts, + gate_fetch_failures=failures, + ) + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_all_gate_failed_marks_banned() -> None: + """(a) ВСЕ gate-API попытки прогона failed extraction → banned, не done.""" + scraper = _yandex_empty_scraper(attempts=4, failures=4) + counters, calls = await _drive_yandex_city_scrapers([scraper]) + assert counters["lots_fetched"] == 0 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_partial_gate_failure_stays_done() -> None: + """(b) один anchor полностью failed, другой успешен → НЕ банится (анти-флап).""" + blocked = _yandex_empty_scraper(attempts=4, failures=4) + ok = _yandex_empty_scraper(attempts=2, failures=0) + _counters, calls = await _drive_yandex_city_scrapers([blocked, ok]) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_honest_empty_stays_done() -> None: + """(c) структура извлеклась валидно (failures=0), но результатов 0 → done.""" + scraper = _yandex_empty_scraper(attempts=4, failures=0) + counters, calls = await _drive_yandex_city_scrapers([scraper]) + assert counters["lots_fetched"] == 0 + assert calls[-1][0] == "mark_done" + + # ── Cian city sweep ─────────────────────────────────────────────────────────── @@ -185,13 +280,18 @@ def _cian_lot(segment: str) -> MagicMock: return MagicMock(listing_segment=segment, house_source=None, house_ext_id=None) -async def _drive_cian_city() -> _DriveResult: +async def _drive_cian_city( + *, city_slug: str | None = None, capture: dict[str, Any] | None = None +) -> _DriveResult: + """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() db = MagicMock() # 3 novostroyki + 2 secondary → newbuilding_only оставит 3. lots = [_cian_lot("novostroyki")] * 3 + [_cian_lot("vtorichnaya")] * 2 scraper = _ctx_scraper(fetch_around_multi_room=AsyncMock(return_value=lots)) save_mock = MagicMock(side_effect=[(3, 0)]) + if capture is not None: + capture["save_mock"] = save_mock cfg = _config() with ( patch(f"{PFX}.CianScraper", return_value=scraper), @@ -204,6 +304,7 @@ async def _drive_cian_city() -> _DriveResult: matcher=MagicMock(), run_id=1, anchors=[(56.84, 60.60, "A1")], + city_slug=city_slug, radius_m=1000, pages_per_anchor=1, request_delay_sec=0.0, @@ -225,10 +326,86 @@ async def test_cian_city_sweep() -> None: assert calls[-1][0] == "mark_done" +# ── #2625: captcha/blocked-vs-empty detect (Cian Redux-state) ───────────────── + + +async def _drive_cian_city_scrapers(scrapers: list[MagicMock]) -> _DriveResult: + """Как _drive_cian_city, но с N явными anchor'ами → N свежих scraper'ов.""" + recorder = _RunsRecorder() + db = MagicMock() + anchors = [(56.84 + i * 0.01, 60.60, f"A{i}") for i in range(len(scrapers))] + save_mock = MagicMock(return_value=(0, 0)) + cfg = _config() + with ( + patch(f"{PFX}.CianScraper", side_effect=scrapers), + patch(f"{PFX}.save_listings", save_mock), + patch(f"{PFX}.runs", recorder), + ): + counters = await run_cian_city_sweep( + db, + config=cfg, + matcher=MagicMock(), + run_id=1, + anchors=anchors, + radius_m=1000, + pages_per_anchor=1, + request_delay_sec=0.0, + detail_top_n=0, + enrich_houses=False, + newbuilding_only=True, + ) + return counters.to_dict(), _normalize(recorder.calls) + + +def _cian_empty_scraper(*, attempts: int, failures: int) -> MagicMock: + """Cian scraper fixture: fetch_around_multi_room возвращает 0 лотов, но incurs + attempts/failures state-extraction counters — как капча (failures==attempts) + или честная пустая выдача (failures=0).""" + return _ctx_scraper( + fetch_around_multi_room=AsyncMock(return_value=[]), + state_extraction_attempts=attempts, + state_extraction_failures=failures, + ) + + +@pytest.mark.asyncio +async def test_cian_city_sweep_all_extraction_failed_marks_banned() -> None: + """(a) ВСЕ SERP state-extraction попытки прогона failed → banned, не done.""" + scraper = _cian_empty_scraper(attempts=4, failures=4) + counters, calls = await _drive_cian_city_scrapers([scraper]) + assert counters["lots_fetched"] == 0 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_cian_city_sweep_partial_extraction_failure_stays_done() -> None: + """(b) один anchor полностью failed, другой успешен → НЕ банится (анти-флап).""" + blocked = _cian_empty_scraper(attempts=4, failures=4) + ok = _cian_empty_scraper(attempts=2, failures=0) + _counters, calls = await _drive_cian_city_scrapers([blocked, ok]) + assert calls[-1][0] == "mark_done" + + +@pytest.mark.asyncio +async def test_cian_city_sweep_honest_empty_stays_done() -> None: + """(c) Redux state извлёкся валидно (failures=0), но офферов 0 → done.""" + scraper = _cian_empty_scraper(attempts=4, failures=0) + counters, calls = await _drive_cian_city_scrapers([scraper]) + assert counters["lots_fetched"] == 0 + assert calls[-1][0] == "mark_done" + + # ── DomClick city sweep ─────────────────────────────────────────────────────── -async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult: +async def _drive_domclick( + *, + lots_n: int, + blocked: bool, + fetch_errors: int = 0, + capture: dict[str, Any] | None = None, +) -> _DriveResult: + """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() db = MagicMock() lots = [MagicMock() for _ in range(lots_n)] @@ -236,9 +413,15 @@ async def _drive_domclick(*, lots_n: int, blocked: bool) -> _DriveResult: fetch_city=AsyncMock(return_value=lots), blocked=blocked, geo_filtered=0, - fetch_errors=0, + fetch_errors=fetch_errors, + # #2670: полный охват по умолчанию — эти фикстуры про блок/ошибки, не про обрыв + # (частичный охват проверяется в test_2670_streak_and_partial_coverage.py). + buckets_completed=len(ROOM_BUCKETS), + buckets_total=len(ROOM_BUCKETS), ) save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else []) + if capture is not None: + capture["save_mock"] = save_mock cfg = _config() with ( patch(f"{PFX}.DomClickScraper", return_value=scraper), @@ -261,18 +444,54 @@ async def test_domclick_city_sweep_done() -> None: @pytest.mark.asyncio -async def test_domclick_city_sweep_blocked_failed() -> None: - """QRATOR-блок + 0 lots → mark_failed (честный статус #1968).""" +async def test_domclick_city_sweep_blocked_banned() -> None: + """QRATOR-блок + 0 lots → mark_banned (честный статус #1968, #2657).""" counters, calls = await _drive_domclick(lots_n=0, blocked=True) assert counters["lots_fetched"] == 0 assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_blocked_with_lots_marks_banned() -> None: + """#2657: блок оборвал бакеты ПОСЛЕ части лотов → banned, не done. + + Прод-случай: 13 из 13 прогонов с blocked=1 уходили в done, потому что + honest-status требовал ещё и lots_fetched == 0. + """ + counters, calls = await _drive_domclick(lots_n=4, blocked=True) + assert counters["lots_fetched"] == 4 + assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_fetch_errors_without_block_stays_failed() -> None: + """#2657 анти-оверрич: 0 лотов + fetch-ошибки, но БЕЗ блока → failed, не banned.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False, fetch_errors=2) + assert counters["blocked"] == 0 + assert counters["errors_count"] == 2 assert calls[-1][0] == "mark_failed" +@pytest.mark.asyncio +async def test_domclick_city_sweep_honest_empty_stays_done() -> None: + """#2657 анти-оверрич: честная пустота (0 лотов, ни блока, ни ошибок) → done.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False) + assert counters["lots_fetched"] == 0 + assert counters["blocked"] == 0 + assert calls[-1][0] == "mark_done" + + # ── Avito newbuilding sweep ─────────────────────────────────────────────────── -async def _drive_nb_sweep() -> _DriveResult: +async def _drive_nb_sweep( + *, capture: dict[str, Any] | None = None, proxy_provider: Any = None +) -> _DriveResult: + """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...) и + avito_scraper_cls (#2616, MagicMock class — инспекция AvitoScraper(...) call_args). + proxy_provider: прокидывается в run_avito_newbuilding_sweep(...) как есть.""" recorder = _RunsRecorder() db = MagicMock() lots = [MagicMock() for _ in range(6)] @@ -281,15 +500,25 @@ async def _drive_nb_sweep() -> _DriveResult: scraper._browser = None scraper.fetch_newbuildings = AsyncMock(return_value=lots) save_mock = MagicMock(side_effect=[(5, 1)]) + avito_scraper_cls = MagicMock(return_value=scraper) + if capture is not None: + capture["save_mock"] = save_mock + capture["avito_scraper_cls"] = avito_scraper_cls cfg = _config() with ( - patch(f"{PFX}.AvitoScraper", return_value=scraper), + patch(f"{PFX}.AvitoScraper", avito_scraper_cls), patch(f"{PFX}.save_listings", save_mock), patch(f"{PFX}.runs", recorder), patch(f"{PFX}.AsyncSession", return_value=_async_session_cm()), ): counters = await run_avito_newbuilding_sweep( - db, config=cfg, matcher=MagicMock(), run_id=1, pages=2, request_delay_sec=0.0 + db, + config=cfg, + matcher=MagicMock(), + run_id=1, + pages=2, + request_delay_sec=0.0, + proxy_provider=proxy_provider, ) return counters.to_dict(), _normalize(recorder.calls) @@ -304,6 +533,23 @@ async def test_avito_newbuilding_sweep() -> None: assert calls[-1][0] == "mark_done" +@pytest.mark.asyncio +async def test_avito_newbuilding_sweep_passes_proxy_provider_to_scraper_constructor() -> None: + """#2616: proxy_provider=X → AvitoScraper(config, proxy_provider=X). + + NOT load-bearing (browser_mode переопределяет scraper._browser напрямую с + shared_bf, построенным с proxy_provider=proxy_provider выше по стеку) — регрессионный + замок консистентности с cian/yandex, см. run_avito_city_sweep эквивалент. + Falsification: без проброса в pipeline.py mock.call_args.kwargs не содержит sentinel. + """ + sentinel = object() + capture: dict[str, Any] = {} + await _drive_nb_sweep(capture=capture, proxy_provider=sentinel) + avito_scraper_cls = capture["avito_scraper_cls"] + avito_scraper_cls.assert_called_once() + assert avito_scraper_cls.call_args.kwargs.get("proxy_provider") is sentinel + + # ── Full loads (smoke через on_bucket) ───────────────────────────────────────── @@ -319,7 +565,8 @@ def _full_load_scraper(buckets: list[tuple[str, list[Any]]]) -> MagicMock: return scraper -async def _drive_full_load(*, source: str) -> _DriveResult: +async def _drive_full_load(*, source: str, capture: dict[str, Any] | None = None) -> _DriveResult: + """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() db = MagicMock() buckets = [ @@ -328,6 +575,8 @@ async def _drive_full_load(*, source: str) -> _DriveResult: ] scraper = _full_load_scraper(buckets) save_mock = MagicMock(side_effect=[(2, 0), (1, 0)]) + if capture is not None: + capture["save_mock"] = save_mock cfg = _config() fn_map = { @@ -365,3 +614,290 @@ async def test_full_load_smoke(source: str) -> None: assert counters["saved_inserted"] == 3 assert counters["saved_updated"] == 0 assert calls[-1][0] == "mark_done" + + +# ── #2616: run_avito_full_load прокидывает proxy_provider в AvitoScraper ───── +# +# run_avito_full_load — единственное из мест создания AvitoScraper в pipeline.py, где +# `async with AvitoScraper(...) as scraper:` реально проходит через __aenter__ (city_sweep/ +# newbuilding_sweep/run_avito_pipeline строят shared BrowserFetcher вручную и переопределяют +# scraper._browser напрямую, минуя __aenter__ — там proxy_provider проброшен для +# консистентности, но не load-bearing). Здесь proxy_provider ДЕЙСТВИТЕЛЬНО обязан долететь +# до конструктора AvitoScraper, иначе build_browser_fetcher(config, "avito") в __aenter__ +# строит BrowserFetcher без пула (env-fallback на мёртвый BROWSER_PROXY_AVITO, #2613). + + +@pytest.mark.asyncio +async def test_avito_full_load_passes_proxy_provider_to_scraper() -> None: + """run_avito_full_load(proxy_provider=X) → AvitoScraper(config, proxy_provider=X). + + Falsification: если pipeline.py перестанет прокидывать proxy_provider в + AvitoScraper(...), mock.call_args.kwargs['proxy_provider'] не будет `sentinel` + (либо ключа не будет вовсе) — assert падает на VALUE, не на TypeError (Mock + принимает любые kwargs, сигнатуру не проверяет). + """ + recorder = _RunsRecorder() + db = MagicMock() + buckets = [("2к:0-5m", [MagicMock(source_id="a1")])] + scraper = _full_load_scraper(buckets) + sentinel = object() + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper) as mock_cls, + patch(f"{PFX}.save_listings", MagicMock(side_effect=[(1, 0)])), + patch(f"{PFX}.runs", recorder), + ): + await run_avito_full_load( + db, + run_id=1, + config=_config(), + matcher=MagicMock(), + proxy_provider=sentinel, + ) + + mock_cls.assert_called_once() + assert mock_cls.call_args.kwargs.get("proxy_provider") is sentinel + + +@pytest.mark.asyncio +async def test_avito_full_load_default_proxy_provider_is_none() -> None: + """Без proxy_provider= — AvitoScraper(config, proxy_provider=None), поведение прежнее.""" + recorder = _RunsRecorder() + db = MagicMock() + buckets = [("2к:0-5m", [MagicMock(source_id="a1")])] + scraper = _full_load_scraper(buckets) + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper) as mock_cls, + patch(f"{PFX}.save_listings", MagicMock(side_effect=[(1, 0)])), + patch(f"{PFX}.runs", recorder), + ): + await run_avito_full_load(db, run_id=1, config=_config(), matcher=MagicMock()) + + mock_cls.assert_called_once() + assert mock_cls.call_args.kwargs.get("proxy_provider") is None + + +# ── #2594: listings.city проставляется из контекста развёртки ──────────────── +# +# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не +# писала. Тесты проверяют save_listings(..., city=...) для yandex/cian city-sweep +# (oblast + EKB-симметрия), domclick (EKB-only city_id) и full_load'ов (ЕКБ вторичка). + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_stamps_city_from_slug() -> None: + """city_slug='kamensk_uralskiy' → save_listings(..., city='Каменск-Уральский').""" + capture: dict[str, Any] = {} + await _drive_yandex_city(city_slug="kamensk_uralskiy", capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args_list[-1].kwargs["city"] == "Каменск-Уральский" + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None: + """city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург').""" + capture: dict[str, Any] = {} + await _drive_yandex_city(city_slug=None, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args_list[-1].kwargs["city"] == "Екатеринбург" + + +@pytest.mark.asyncio +async def test_cian_city_sweep_stamps_city_from_slug() -> None: + """city_slug='pervouralsk' → save_listings(..., city='Первоуральск').""" + capture: dict[str, Any] = {} + await _drive_cian_city(city_slug="pervouralsk", capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city"] == "Первоуральск" + + +@pytest.mark.asyncio +async def test_cian_city_sweep_stamps_ekaterinburg_when_no_city_slug() -> None: + """city_slug=None (ЕКБ-развёртка) → save_listings(..., city='Екатеринбург').""" + capture: dict[str, Any] = {} + await _drive_cian_city(city_slug=None, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city"] == "Екатеринбург" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_stamps_ekaterinburg_for_default_city_id() -> None: + """city_id=DOMCLICK_DEFAULT_CITY_ID (4, ЕКБ) → save_listings(..., city='Екатеринбург').""" + capture: dict[str, Any] = {} + await _drive_domclick(lots_n=4, blocked=False, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city"] == "Екатеринбург" + + +@pytest.mark.asyncio +async def test_avito_newbuilding_sweep_stamps_ekaterinburg() -> None: + """Citywide novostroyka-обход — только ЕКБ → save_listings(..., city='Екатеринбург').""" + capture: dict[str, Any] = {} + await _drive_nb_sweep(capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city"] == "Екатеринбург" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["avito", "cian", "yandex"]) +async def test_full_load_stamps_ekaterinburg(source: str) -> None: + """Exhaustive региональный сбор — только ЕКБ вторичка → city='Екатеринбург' на КАЖДОМ + бакете (on_bucket сохраняет инкрементально, не один batch на весь run).""" + capture: dict[str, Any] = {} + await _drive_full_load(source=source, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_count > 0 + for call in save_mock.call_args_list: + assert call.kwargs["city"] == "Екатеринбург" + + +# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ── +# +# Замер на проде (см. PR): oblast city-sweep (yandex/cian) стамповал город-цель на +# лоты, физически лежащие в куда более крупном ЕКБ (у yandex дело не в radius_m +# запроса — gate-API скоупит city-scoped rgid и игнорирует lat/lon/radius_m целиком, +# см. providers/yandex/serp.py:fetch_around; anchor В.Пышмы всего ~15.3км от центра +# ЕКБ, соседние агломерации почти смыкаются). Оркестратор обязан передать +# city_anchor/city_radius_km для oblast-города и НЕ передавать (None/None) для ЕКБ +# (нет большего соседа — guard там не нужен). + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None: + """city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km + из pipeline.get_city_anchor_point/get_city_stamp_radius_km.""" + from scraper_kit.orchestration.pipeline import ( + get_city_anchor_point, + get_city_stamp_radius_km, + ) + + capture: dict[str, Any] = {} + await _drive_yandex_city(city_slug="verkhnyaya_pyshma", capture=capture) + save_mock = capture["save_mock"] + call = save_mock.call_args_list[-1] + assert call.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma") + assert call.kwargs["city_radius_km"] == get_city_stamp_radius_km("verkhnyaya_pyshma") + + +@pytest.mark.asyncio +async def test_yandex_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None: + """city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None — + ЕКБ-развёртка не ломается геопроверкой (нет города крупнее ЕКБ в регионе).""" + capture: dict[str, Any] = {} + await _drive_yandex_city(city_slug=None, capture=capture) + save_mock = capture["save_mock"] + call = save_mock.call_args_list[-1] + assert call.kwargs["city_anchor"] is None + assert call.kwargs["city_radius_km"] is None + + +@pytest.mark.asyncio +async def test_cian_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None: + """city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km.""" + from scraper_kit.orchestration.pipeline import ( + get_city_anchor_point, + get_city_stamp_radius_km, + ) + + capture: dict[str, Any] = {} + await _drive_cian_city(city_slug="verkhnyaya_pyshma", capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma") + assert save_mock.call_args.kwargs["city_radius_km"] == get_city_stamp_radius_km( + "verkhnyaya_pyshma" + ) + + +@pytest.mark.asyncio +async def test_cian_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None: + """city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None.""" + capture: dict[str, Any] = {} + await _drive_cian_city(city_slug=None, capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_args.kwargs["city_anchor"] is None + assert save_mock.call_args.kwargs["city_radius_km"] is None + + +# ── #2625: captcha/blocked-vs-empty detect — full loads (cian/yandex) ───────── + +_FULL_LOAD_FN = {"cian": run_cian_full_load, "yandex": run_yandex_full_load} +_FULL_LOAD_SCRAPER_TARGET = { + "cian": f"{PFX}.CianScraper", + "yandex": f"{PFX}.YandexRealtyScraper", +} +_FULL_LOAD_ATTR = { + "cian": ("state_extraction_attempts", "state_extraction_failures"), + "yandex": ("gate_fetch_attempts", "gate_fetch_failures"), +} + + +async def _drive_full_load_empty(*, source: str, attempts: int, failures: int) -> _DriveResult: + """Full load с 0 бакетов через on_bucket (captcha скипает все бакеты — SKIP/ + DEGRADE-политика бисекции), но с явными extraction attempts/failures на scraper.""" + recorder = _RunsRecorder() + db = MagicMock() + attempts_attr, failures_attr = _FULL_LOAD_ATTR[source] + + async def _fetch(*_a: Any, on_bucket: Any = None, on_progress: Any = None, **_k: Any) -> None: + return None + + scraper = _ctx_scraper( + fetch_all_secondary=_fetch, + **{attempts_attr: attempts, failures_attr: failures}, + ) + scraper._browser = None + save_mock = MagicMock(return_value=(0, 0)) + cfg = _config() + + extra: dict[str, Any] = {} + if source == "yandex": + enrichment = MagicMock() + enrichment.record_yandex_price_history = MagicMock(return_value=0) + extra["enrichment"] = enrichment + with ( + patch(_FULL_LOAD_SCRAPER_TARGET[source], return_value=scraper), + patch(f"{PFX}.save_listings", save_mock), + patch(f"{PFX}.runs", recorder), + ): + counters = await _FULL_LOAD_FN[source]( + db, run_id=1, config=cfg, matcher=MagicMock(), **extra + ) + return counters.to_dict(), _normalize(recorder.calls) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["cian", "yandex"]) +async def test_full_load_all_extraction_failed_marks_banned(source: str) -> None: + """(a) весь региональный проход не смог извлечь структуру ни разу → banned.""" + counters, calls = await _drive_full_load_empty(source=source, attempts=6, failures=6) + assert counters["unique_fetched"] == 0 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["cian", "yandex"]) +async def test_full_load_honest_empty_stays_done(source: str) -> None: + """(c) структура извлекалась валидно (failures=0) на всех попытках → done.""" + counters, calls = await _drive_full_load_empty(source=source, attempts=6, failures=0) + assert counters["unique_fetched"] == 0 + assert calls[-1][0] == "mark_done" + + +# ── #2701: снимок обязан знать свой прогон ──────────────────────────────────── +# +# Замер на проде до правки: run_id пуст у 150 515 из 396 162 снимков (38%). +# Из девяти вызовов save_listings в pipeline.py шесть передавали run_id, три нет; +# у двух из трёх (city sweep + этот novostroyka-обход) run_id лежал в той же функции. + + +@pytest.mark.asyncio +async def test_avito_newbuilding_sweep_passes_run_id_to_save_listings() -> None: + """run_avito_newbuilding_sweep(run_id=1) → save_listings(..., run_id=1). + + Falsification: убрать `run_id=run_id` из вызова save_listings — kwargs пуст, assert падает. + """ + capture: dict[str, Any] = {} + await _drive_nb_sweep(capture=capture) + save_mock = capture["save_mock"] + assert save_mock.call_count > 0 + for call in save_mock.call_args_list: + assert call.kwargs.get("run_id") == 1, "novostroyka-снимок остался бы без прогона" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py index 532090ba..7fb52207 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py @@ -166,3 +166,27 @@ def test_build_browser_fetcher_explicit_timeout_override() -> None: fetcher = build_browser_fetcher(config, "yandex", fetch_timeout_s=30.0) # type: ignore[arg-type] assert fetcher._fetch_timeout_s == 30.0 + + +# ── environment threading (#2616 шаг 1) ────────────────────────────────────── + + +def test_build_browser_fetcher_defaults_environment_dev_when_config_lacks_field() -> None: + """_FakeConfig (минимальная заглушка) не имеет поля `environment` — getattr-дефолт + "dev" защищает от AttributeError и не меняет поведение старых/минимальных фейков. + """ + config = _FakeConfig(endpoint="http://browser:3000") + + fetcher = build_browser_fetcher(config, "cian") # type: ignore[arg-type] + + assert fetcher._environment == "dev" + + +def test_build_browser_fetcher_threads_environment_from_config() -> None: + """config.environment (когда есть) прокидывается в BrowserFetcher как есть.""" + config = _FakeConfig(endpoint="http://browser:3000", use_pool=True) + config.environment = "production" # type: ignore[attr-defined] + + fetcher = build_browser_fetcher(config, "avito") # type: ignore[arg-type] + + assert fetcher._environment == "production" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index a4499362..11a742f4 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -35,6 +35,8 @@ from scraper_kit.orchestration.scheduler import ( _claim_run, _dispatch, _job_avito_city_sweep, + _job_avito_full_load, + _job_avito_full_load_exhaustive, _job_cian_city_sweep, _job_yandex_city_sweep, build_registry, @@ -57,6 +59,7 @@ _PRODUCT_SOURCES: set[str] = { "rosreestr_dkp_import", "listing_source_snapshot", "asking_to_sold_ratio_refresh", + "deal_city_price_bands_refresh", "refresh_search_matview", "yandex_address_backfill", "deactivate_stale_avito", @@ -235,6 +238,8 @@ class _FakeClaimDB: self.committed = False self.rolled_back = False self.update_calls = 0 + # #2658: строки-пропуски (scrape_runs status='skipped'), которые пишет mark_skipped. + self.skip_rows = 0 def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) @@ -246,6 +251,13 @@ class _FakeClaimDB: if "UPDATE scrape_schedules" in sql: self.update_calls += 1 return _FakeResult() + # #2658 mark_skipped: сначала пробует схлопнуть последнюю skip-строку (UPDATE + # ... RETURNING), не нашёл — вставляет новую. + if "UPDATE scrape_runs r" in sql: + return _FakeResult(fetchone=None) + if "INSERT INTO scrape_runs" in sql: + self.skip_rows += 1 + return _FakeResult(fetchone=MagicMock(id=999)) return _FakeResult() def commit(self) -> None: @@ -283,6 +295,7 @@ def test_claim_run_skip_already_running() -> None: run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx) assert run_id is None ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658: пропуск оставляет строку, а не только лог def test_claim_run_skip_lock_busy() -> None: @@ -292,6 +305,7 @@ def test_claim_run_skip_lock_busy() -> None: run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx) assert run_id is None ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658 def test_claim_run_running_appeared_under_lock() -> None: @@ -302,6 +316,7 @@ def test_claim_run_running_appeared_under_lock() -> None: assert run_id is None assert db.rolled_back is True ctx.runs.create_run.assert_not_called() + assert db.skip_rows == 1 # #2658 # ── 3. reap_zombies ────────────────────────────────────────────────────────── @@ -516,6 +531,56 @@ async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None: assert kwargs["anchors"] is None +# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ────── +# +# run_avito_full_load — единственный avito sweep, где было НЕ прокинуто proxy_provider +# вообще (ни в сигнатуре run_avito_full_load, ни на call-site здесь) — asymmetric +# с _job_avito_city_sweep/_job_avito_newbuilding_sweep (proxy_provider=ctx.proxy_provider +# уже были) и с _job_cian_full_load (уже пробрасывал). Root cause 13/14 banned avito_full_load +# прогонов за 14 дней (issue #2613/#2616). + + +def _proxy_ctx(sentinel: object) -> SchedulerContext: + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=MagicMock(), + proxy_provider=sentinel, + ) + + +async def test_job_avito_full_load_passes_proxy_provider() -> None: + """_job_avito_full_load(ctx) → run_avito_full_load(..., proxy_provider=ctx.proxy_provider). + + Falsification: если scheduler.py перестанет прокидывать proxy_provider=ctx.proxy_provider + в run_avito_full_load(...), kwargs['proxy_provider'] не будет `sentinel` (либо ключа не + будет вовсе) — assert падает на VALUE, не на TypeError (AsyncMock не проверяет сигнатуру). + """ + sentinel = object() + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load(MagicMock(), 1, {}, _proxy_ctx(sentinel)) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs.get("proxy_provider") is sentinel + + +async def test_job_avito_full_load_exhaustive_passes_proxy_provider() -> None: + """_job_avito_full_load_exhaustive(ctx) → + + run_avito_full_load(..., proxy_provider=ctx.proxy_provider). + """ + sentinel = object() + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load_exhaustive(MagicMock(), 1, {}, _proxy_ctx(sentinel)) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs.get("proxy_provider") is sentinel + + def test_wildcard_resolves_oblast_city_sweep_sources() -> None: """resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*".""" registry = build_registry() diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py index eb05c6aa..165539d8 100644 --- a/tradein-mvp/backend/tests/test_scraper_proxy.py +++ b/tradein-mvp/backend/tests/test_scraper_proxy.py @@ -1,8 +1,9 @@ -"""Tests for scraper proxy wiring (#806 + follow-up). +"""Tests for scraper proxy wiring (#806 + #2616 шаг 2 cleanup). Covers: - - scraper_proxy_url property: SCRAPER_PROXY_URL takes precedence over AVITO_PROXY_URL - - AVITO_PROXY_URL fallback works when SCRAPER_PROXY_URL is absent + - scraper_proxy_url property: reads SCRAPER_PROXY_URL only. Legacy per-provider + AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL fallback removed (#2616 шаг 2 — + dead mobileproxy accounts, 407/connection refused, #2613). - None/empty → direct connection (no proxies dict) - _avito_proxies() helper returns correct dict shape - per-scraper proxy wiring: YandexRealty, YandexValuation (kit), CianValuation, @@ -46,12 +47,10 @@ def _mock_settings( """Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__.""" return SimpleNamespace( scraper_proxy_url=scraper_proxy_url, - # Cian scrapers read settings.cian_proxy_url (property: CIAN_PROXY_URL or - # scraper_proxy_url fallback). Mirror scraper_proxy_url here so the existing - # cian proxy-wiring assertions keep passing (#923 follow-up). + # cian_proxy_url / yandex_proxy_url properties now mirror scraper_proxy_url + # 1:1 (#2616 шаг 2: per-provider CIAN_PROXY_URL/YANDEX_PROXY_URL override + # removed — dead mobileproxy accounts). Mirrored here for test compat. cian_proxy_url=scraper_proxy_url, - # Yandex scrapers read settings.yandex_proxy_url (property: YANDEX_PROXY_URL or - # scraper_proxy_url fallback). Mirror scraper_proxy_url here for test compat. yandex_proxy_url=scraper_proxy_url, yandex_cookies_file=yandex_cookies_file, ) @@ -62,44 +61,23 @@ def _mock_settings( # pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule. -def _resolve_scraper_proxy_url( - scraper_proxy_url_env: str | None, avito_proxy_url: str | None -) -> str | None: - """Inline replica of Settings.scraper_proxy_url property logic.""" - return scraper_proxy_url_env or avito_proxy_url +def _resolve_scraper_proxy_url(scraper_proxy_url_env: str | None) -> str | None: + """Inline replica of Settings.scraper_proxy_url property logic (#2616 шаг 2: + legacy AVITO_PROXY_URL fallback removed — SCRAPER_PROXY_URL is now the sole + source, projected as-is).""" + return scraper_proxy_url_env def test_scraper_proxy_url_uses_scraper_env_when_set(): - result = _resolve_scraper_proxy_url( - scraper_proxy_url_env="http://scraper-proxy:1234", - avito_proxy_url="http://avito-proxy:5678", - ) + result = _resolve_scraper_proxy_url(scraper_proxy_url_env="http://scraper-proxy:1234") assert result == "http://scraper-proxy:1234" -def test_scraper_proxy_url_falls_back_to_avito_proxy_url(): - """AVITO_PROXY_URL used when SCRAPER_PROXY_URL absent — zero env change on prod.""" - result = _resolve_scraper_proxy_url( - scraper_proxy_url_env=None, - avito_proxy_url="http://avito-proxy:5678", - ) - assert result == "http://avito-proxy:5678" - - -def test_scraper_proxy_url_none_when_both_absent(): - result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None, avito_proxy_url=None) +def test_scraper_proxy_url_none_when_absent(): + result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None) assert result is None -def test_scraper_proxy_url_empty_string_falls_through_to_avito(): - """Empty string is falsy — falls through to avito_proxy_url.""" - result = _resolve_scraper_proxy_url( - scraper_proxy_url_env="", - avito_proxy_url="http://avito-proxy:5678", - ) - assert result == "http://avito-proxy:5678" - - # ── _avito_proxies() dict shape ─────────────────────────────────────────────── @@ -121,22 +99,8 @@ def test_avito_proxies_returns_none_when_no_proxy(): assert result is None -def test_avito_proxies_fallback_via_avito_proxy_url(): - """_avito_proxies(config) uses AVITO_PROXY_URL via scraper_proxy_url fallback. - - When config.scraper_proxy_url resolves to the avito URL (fallback path), - the dict shape must be {"http": url, "https": url}. - """ - from scraper_kit.orchestration.pipeline import _avito_proxies - - avito_url = "http://mobile-proxy.space:9090" - result = _avito_proxies(_mock_settings(avito_url)) - - assert result == {"http": avito_url, "https": avito_url} - - # ── Real pydantic env-binding (regression for #806 fixup) ───────────────────── -# The inline-replica tests above prove the OR-logic but NOT that the field +# The inline-replica tests above prove the property logic but NOT that the field # actually binds to env SCRAPER_PROXY_URL. `validation_alias` is what makes that # work — without it pydantic-settings reads SCRAPER_PROXY_URL_ENV (the field # name), so setting SCRAPER_PROXY_URL would be a silent no-op. These tests @@ -151,26 +115,17 @@ def _fresh_settings(): def test_settings_binds_scraper_proxy_url_env(monkeypatch): monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234") - monkeypatch.delenv("AVITO_PROXY_URL", raising=False) assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234" -def test_settings_scraper_env_overrides_avito(monkeypatch): - monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234") - monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678") - assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234" - - -def test_settings_falls_back_to_avito_env(monkeypatch): +def test_settings_scraper_proxy_url_none_when_unset(monkeypatch): monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) - monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678") - assert _fresh_settings().scraper_proxy_url == "http://avito-proxy:5678" + assert _fresh_settings().scraper_proxy_url is None def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch): """Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind.""" monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) - monkeypatch.delenv("AVITO_PROXY_URL", raising=False) monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111") assert _fresh_settings().scraper_proxy_url is None @@ -378,60 +333,27 @@ async def test_avito_detail_shared_session_not_patched(): # ── Settings.cian_proxy_url property ───────────────────────────────────────── -# cian_proxy_url: CIAN_PROXY_URL > scraper_proxy_url (fallback) > None. +# #2616 шаг 2: dedicated CIAN_PROXY_URL removed (dead mobileproxy account, #2613) — +# cian_proxy_url now mirrors scraper_proxy_url 1:1. Property kept (not inlined at +# call sites) for scraper_kit.contracts.ScraperConfig Protocol compatibility. -def _resolve_cian_proxy_url( - cian_proxy_url_env: str | None, scraper_proxy_url: str | None -) -> str | None: - """Inline replica of Settings.cian_proxy_url property logic.""" - return cian_proxy_url_env or scraper_proxy_url +def _resolve_cian_proxy_url(scraper_proxy_url: str | None) -> str | None: + """Inline replica of Settings.cian_proxy_url property logic (post #2616 шаг 2).""" + return scraper_proxy_url -def test_cian_proxy_url_uses_cian_env_when_set(): - """CIAN_PROXY_URL takes precedence over shared scraper_proxy_url.""" - result = _resolve_cian_proxy_url( - cian_proxy_url_env="http://cian-proxy:2222", - scraper_proxy_url="http://shared-proxy:1111", - ) - assert result == "http://cian-proxy:2222" +def test_cian_proxy_url_mirrors_scraper_proxy_url(): + assert _resolve_cian_proxy_url("http://shared-proxy:1111") == "http://shared-proxy:1111" -def test_cian_proxy_url_falls_back_to_scraper_proxy_url(): - """Falls back to shared scraper_proxy_url when CIAN_PROXY_URL is absent.""" - result = _resolve_cian_proxy_url( - cian_proxy_url_env=None, - scraper_proxy_url="http://shared-proxy:1111", - ) - assert result == "http://shared-proxy:1111" +def test_cian_proxy_url_none_when_absent(): + assert _resolve_cian_proxy_url(None) is None -def test_cian_proxy_url_none_when_both_absent(): - result = _resolve_cian_proxy_url(cian_proxy_url_env=None, scraper_proxy_url=None) - assert result is None - - -def test_settings_binds_cian_proxy_url_env(monkeypatch): - """CIAN_PROXY_URL env binds to cian_proxy_url_env via validation_alias.""" - monkeypatch.setenv("CIAN_PROXY_URL", "http://cian-proxy:2222") - monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) - monkeypatch.delenv("AVITO_PROXY_URL", raising=False) - assert _fresh_settings().cian_proxy_url == "http://cian-proxy:2222" - - -def test_settings_cian_env_overrides_scraper_proxy(monkeypatch): - """CIAN_PROXY_URL overrides shared SCRAPER_PROXY_URL for Cian scrapers.""" - monkeypatch.setenv("CIAN_PROXY_URL", "http://cian-proxy:2222") +def test_settings_cian_proxy_url_mirrors_scraper_proxy_url(monkeypatch): + """cian_proxy_url tracks scraper_proxy_url 1:1 — no more independent CIAN_PROXY_URL.""" monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111") - monkeypatch.delenv("AVITO_PROXY_URL", raising=False) s = _fresh_settings() - assert s.cian_proxy_url == "http://cian-proxy:2222" - assert s.scraper_proxy_url == "http://shared-proxy:1111" - - -def test_settings_cian_proxy_falls_back_to_scraper(monkeypatch): - """Without CIAN_PROXY_URL, cian_proxy_url falls back to scraper_proxy_url.""" - monkeypatch.delenv("CIAN_PROXY_URL", raising=False) - monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111") - monkeypatch.delenv("AVITO_PROXY_URL", raising=False) - assert _fresh_settings().cian_proxy_url == "http://shared-proxy:1111" + assert s.cian_proxy_url == "http://shared-proxy:1111" + assert s.cian_proxy_url == s.scraper_proxy_url diff --git a/tradein-mvp/backend/tests/test_search_api.py b/tradein-mvp/backend/tests/test_search_api.py index 0a7fc383..e6c6c1ad 100644 --- a/tradein-mvp/backend/tests/test_search_api.py +++ b/tradein-mvp/backend/tests/test_search_api.py @@ -39,7 +39,6 @@ def test_build_query_all_filters(): area_m2_max=80, price_rub_max=10_000_000, year_built_min=2000, - has_kadastr=True, multi_source_only=True, require_avito=True, address_query="Малышева", @@ -49,13 +48,28 @@ def test_build_query_all_filters(): assert "total_area >= CAST(:area_min AS double precision)" in sql assert "price_rub <= CAST(:price_max AS bigint)" in sql assert "year_built >= CAST(:yb_min AS integer)" in sql - assert "cadastral_number IS NOT NULL" in sql assert "source_count >= 2" in sql assert "has_avito = true" in sql assert "address ILIKE" in sql assert args["addr_like"] == "%Малышева%" +def test_no_unsatisfiable_cadastral_filter() -> None: + """#2674: фильтр по кадастру снят — он мог вернуть только пустую выдачу. + + `listings.cadastral_number` (кадастр КВАРТИРЫ) пуст у всех 93 408 объявлений на проде: + площадки его не отдают. Предикат `cadastral_number IS NOT NULL` в WHERE обещал + пользователю фильтр качества данных, которого не существует. Ни один набор параметров + поиска не должен уметь его собрать. RED до фикса: has_kadastr=True вставлял предикат. + """ + for params in ( + SearchParams(), + SearchParams(**{"has_kadastr": True}), # старый клиент — параметр игнорируется + ): + sql, _ = build_search_query(params) + assert "cadastral_number IS NOT NULL" not in sql + + def test_build_query_sources_array(): sql, args = build_search_query(SearchParams(sources=["avito", "cian"])) assert "sources && CAST(:sources AS text[])" in sql @@ -150,7 +164,7 @@ def test_search_cache_hit(_reset_cache_singleton): } _reset_cache_singleton.get = AsyncMock(return_value=cached_payload) client = TestClient(app) - r = client.post("/api/v1/search", json={"rooms": 2}) + r = client.post("/api/v1/search", json={"rooms": 2}, headers={"X-Authenticated-User": "admin"}) assert r.status_code == 200 assert r.json()["cache_hit"] is True _reset_cache_singleton.set.assert_not_awaited() diff --git a/tradein-mvp/backend/tests/test_snapshot_writer.py b/tradein-mvp/backend/tests/test_snapshot_writer.py index df1aafde..a779c98d 100644 --- a/tradein-mvp/backend/tests/test_snapshot_writer.py +++ b/tradein-mvp/backend/tests/test_snapshot_writer.py @@ -119,7 +119,7 @@ def test_upsert_snapshot_params_minimal(): assert p["run_id"] is None assert p["snap_date"] is None assert p["ppm2"] is None - assert p["pos"] is None + assert "pos" not in p # #2674: position_in_serp больше не пишется assert p["status"] == "active" @@ -134,7 +134,6 @@ def test_upsert_snapshot_params_full(): price_per_m2=130_000, run_id=99, snapshot_date=snap_date, - position_in_serp=3, status="active", ) @@ -144,10 +143,30 @@ def test_upsert_snapshot_params_full(): assert params["ppm2"] == 130_000 assert params["run_id"] == 99 assert params["snap_date"] == snap_date - assert params["pos"] == 3 assert params["status"] == "active" +def test_upsert_snapshot_does_not_write_position_in_serp(): + """#2674: колонка не упоминается ни в сигнатуре, ни в SQL. + + Диагноз — «механизм невыразим»: PK (listing_id, snapshot_date) держит одну строку + на объявление в СУТКИ, а позиция есть свойство конкретного прогона выдачи с + конкретными фильтрами (в одни сутки по ключу пишут до 13 прогонов и 4 разных + SERP-источника). Значение осело бы от последнего писателя дня и читалось бы как + факт. Тест ловит попытку «подключить проводку» обратно. + """ + import inspect + + from scraper_kit.snapshot_writer import upsert_listing_snapshot as fn + + assert "position_in_serp" not in inspect.signature(fn).parameters + + db = _mock_db_simple() + upsert_listing_snapshot(db, listing_id=1, price_rub=1_000_000) + sql = str(db.execute.call_args_list[0].args[0]) + assert "position_in_serp" not in sql + + def test_upsert_snapshot_no_commit(): """upsert_listing_snapshot НЕ вызывает db.commit() — commit делает caller.""" db = _mock_db_simple() diff --git a/tradein-mvp/backend/tests/test_support.py b/tradein-mvp/backend/tests/test_support.py index 4b87d446..f89370ad 100644 --- a/tradein-mvp/backend/tests/test_support.py +++ b/tradein-mvp/backend/tests/test_support.py @@ -87,7 +87,11 @@ def client(db: MagicMock) -> TestClient: yield db app.dependency_overrides[get_db] = fake_db - return TestClient(app) + # https, а не дефолтный http: анонимная ветка ставит идентити-куку с + # `secure=True` (как session-cookie), и по http httpx её не вернул бы в + # следующем запросе — тесты «тот же тред / тот же бюджет лимита» тихо + # проверяли бы каждый раз НОВОГО анонима. Прод и так только https. + return TestClient(app, base_url="https://testserver") def _auth(username: str = "alice") -> dict[str, str]: @@ -417,17 +421,19 @@ def test_list_messages_returns_thread_scoped_rows( monkeypatch.setattr( support_module.storage, "list_messages", - lambda db, *, thread_id, since_id, limit: [ - { - "id": 1, - "direction": "in", - "text_body": "hi", - "operator_tg_id": None, - "created_at": "2026-07-26T00:00:00+00:00", - } - ] - if thread_id == 7 - else [], + lambda db, *, thread_id, since_id, limit: ( + [ + { + "id": 1, + "direction": "in", + "text_body": "hi", + "operator_tg_id": None, + "created_at": "2026-07-26T00:00:00+00:00", + } + ] + if thread_id == 7 + else [] + ), ) r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice")) assert r.status_code == 200 @@ -534,3 +540,194 @@ def test_mark_read_calls_storage_when_thread_exists( assert r.status_code == 200 assert mark_called == [7] assert db.commit.called + + +# ── анонимная ветка: поддержка без входа (инцидент 2026-07-31) ──────────────── + + +@pytest.fixture(autouse=True) +def _fresh_anon_ip_limiter(monkeypatch: pytest.MonkeyPatch) -> None: + """Как `_fresh_rate_limiter`, но для per-IP бюджета анонимной ветки — иначе + состояние течёт между тестами в одном процессе pytest.""" + monkeypatch.setattr( + support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0) + ) + + +def _patch_anon_storage(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Мокает storage для send-пути и возвращает список ключей тредов, с которыми + его позвали (проверяем, что аноним адресуется `anon:`, а не логином).""" + seen_keys: list[str] = [] + + def fake_get_or_create(db: Any, username: str) -> int: + seen_keys.append(username) + return 1 + + monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create) + monkeypatch.setattr( + support_module.storage, + "record_inbound", + lambda *a, **kw: { + "id": 1, + "direction": "in", + "text_body": kw["text_body"], + "operator_tg_id": None, + "created_at": "2026-07-31T00:00:00+00:00", + }, + ) + return seen_keys + + +def test_anon_send_without_any_auth_succeeds_and_sets_cookie( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Суть фичи: залогиниться нельзя, а написать в поддержку — можно.""" + seen_keys = _patch_anon_storage(monkeypatch) + + r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"}) + assert r.status_code == 200, r.text + assert r.json()["text_body"] == "не могу войти" + + token = client.cookies.get(support_module._ANON_COOKIE_NAME) + assert token is not None + assert support_module._ANON_TOKEN_RE.match(token) + # Тред адресован анонимным ключом, не голым токеном и не чьим-то логином. + assert seen_keys == [f"anon:{token}"] + + +def test_anon_cookie_reused_across_messages_same_thread( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + seen_keys = _patch_anon_storage(monkeypatch) + + client.post("/api/v1/trade-in/support/anon/messages", json={"text": "первое"}) + token_after_first = client.cookies.get(support_module._ANON_COOKIE_NAME) + client.post("/api/v1/trade-in/support/anon/messages", json={"text": "второе"}) + + assert client.cookies.get(support_module._ANON_COOKIE_NAME) == token_after_first + assert seen_keys == [f"anon:{token_after_first}"] * 2 + + +def test_anon_mirror_is_labelled_and_never_leaks_token( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + """Оператор видит, что это НЕзалогиненный посетитель, но bearer треда в + Telegram-топик не уходит (топик читают люди и пересылают дальше).""" + _patch_anon_storage(monkeypatch) + + client.post("/api/v1/trade-in/support/anon/messages", json={"text": "помогите"}) + token = client.cookies.get(support_module._ANON_COOKIE_NAME) + sent_text = _fake_telegram_client.calls[-1]["text"] + + assert sent_text.startswith("[С САЙТА · БЕЗ ВХОДА] anon-") + assert "помогите" in sent_text + assert token not in sent_text + assert support_module._anon_display_id(token) in sent_text + + +def test_anon_read_paths_without_cookie_are_empty_not_401(client: TestClient) -> None: + """Виджет поллит эти ручки ДО первого сообщения — 401 там был бы ложной ошибкой.""" + assert client.get("/api/v1/trade-in/support/anon/messages").status_code == 200 + assert client.get("/api/v1/trade-in/support/anon/messages").json() == [] + assert client.get("/api/v1/trade-in/support/anon/unread").json() == {"unread": 0} + assert client.post("/api/v1/trade-in/support/anon/read").json() == {"status": "ok"} + + +def test_anon_malformed_cookie_ignored_and_never_reaches_storage( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Кука клиент-контролируема: мусор из браузера не должен становиться ключом + треда. Считаем куку отсутствующей и выдаём новую.""" + seen_keys = _patch_anon_storage(monkeypatch) + bogus = "not-a-valid-token!@#$%^" + client.cookies.set(support_module._ANON_COOKIE_NAME, bogus) + + r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"}) + assert r.status_code == 200 + + assert len(seen_keys) == 1 + assert bogus not in seen_keys[0] + assert seen_keys[0].startswith("anon:") + assert support_module._ANON_TOKEN_RE.match(seen_keys[0].removeprefix("anon:")) + + +def test_anon_read_path_with_malformed_cookie_returns_empty( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + find_calls = [] + monkeypatch.setattr( + support_module.storage, + "find_thread_id", + lambda db, username: find_calls.append(username), + ) + client.cookies.set(support_module._ANON_COOKIE_NAME, "!!not-a-token!!") + + assert client.get("/api/v1/trade-in/support/anon/messages").json() == [] + assert find_calls == [] # до storage мусор не доехал вообще + + +def test_anon_per_ip_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """Главный анти-абуз: per-token бюджет обходится сбросом куки, per-IP — нет.""" + _patch_anon_storage(monkeypatch) + monkeypatch.setattr( + support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1, window_s=60.0) + ) + + assert ( + client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200 + ) + # Ротация куки НЕ спасает — бюджет привязан к IP. + client.cookies.delete(support_module._ANON_COOKIE_NAME) + r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}) + assert r.status_code == 429 + assert "Retry-After" in r.headers + + +def test_anon_per_token_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_anon_storage(monkeypatch) + monkeypatch.setattr( + support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0) + ) + + assert ( + client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200 + ) + assert ( + client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}).status_code == 429 + ) + + +def test_anon_failed_send_sets_no_cookie_and_writes_nothing( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + """Неудачная отправка не должна закреплять за посетителем пустой тред.""" + seen_keys = _patch_anon_storage(monkeypatch) + _fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom") + + r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"}) + assert r.status_code == 502 + assert seen_keys == [] + assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None + + +def test_anon_bot_not_configured_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(support_module.settings, "telegram_bot_token", "") + r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"}) + assert r.status_code == 503 + assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None + + +def test_anon_blank_text_422(client: TestClient) -> None: + r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": " "}) + assert r.status_code == 422 + + +def test_anon_thread_key_cannot_collide_with_real_username() -> None: + """Инвариант изоляции: `anon:` невозможен в реальном логине (CHECK миграции + 193 + Pydantic `^[A-Za-z0-9._-]{3,64}$`), значит аноним структурно не может + попасть в тред существующего пользователя.""" + from app.schemas.team import _USERNAME_RE + + key = support_module._anon_thread_key(support_module.secrets.token_urlsafe(18)) + assert key.startswith("anon:") + assert _USERNAME_RE.match(key) is None diff --git a/tradein-mvp/backend/tests/test_sweep_imv_phase.py b/tradein-mvp/backend/tests/test_sweep_imv_phase.py index 3e7417ea..38c56305 100644 --- a/tradein-mvp/backend/tests/test_sweep_imv_phase.py +++ b/tradein-mvp/backend/tests/test_sweep_imv_phase.py @@ -54,15 +54,12 @@ def _config() -> SimpleNamespace: scraper_fetch_mode="curl_cffi", browser_http_endpoint="http://browser.test/fetch", scraper_proxy_url=None, - avito_proxy_rotate_url=None, avito_proxy_max_rotations=0, avito_serp_ok_not_banned=True, avito_proxy_rotate_settle_s=0.0, proxy_rotate_attempts=1, proxy_rotate_attempt_timeout_s=1.0, - cian_proxy_rotate_url=None, cian_proxy_max_rotations=0, - yandex_proxy_rotate_url=None, yandex_proxy_max_rotations=0, scraper_skip_seen_today=False, ) diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py new file mode 100644 index 00000000..0a06cb60 --- /dev/null +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -0,0 +1,1416 @@ +"""Integration tests for #2554 team-management API — employees CRUD, quotas, history. + +Same pattern as `tests/test_auth_api.py`: real `rbac_guard` + real `auth.router` / +`team.router` wired into an isolated FastAPI test app, with an in-memory fake DB +(`_Store`/`_FakeDB`) dispatching on SQL text standing in for реестра людей / +`account_quota_overrides` / `account_estimate_usage` / `user_events` / +`trade_in_estimates`. + +Сессия РЕЕСТРА подменяется на самом низком уровне (`identity_store.SessionLocal` ++ `auth_db.auth_session`, см. `tests.support.identity_modes.patch_identity_sessions`), +а `app.core.db.get_db` — через `app.dependency_overrides`. Поэтому и +`identity_session()` (rbac_guard — middleware, FastAPI-DI там нет), и +`Depends(get_identity_db)` (`current_team_actor`, все team-роуты) выполняются +НАСТОЯЩИЕ, вместе со своим ветвлением по `settings.identity_store`. Все они +смотрят в ОДИН `_Store` на тест — сессия из POST /login сразу видна и +rbac_guard'у, и `current_team_actor`. + +ДВЕ СЕССИИ. В дефолтном режиме `get_identity_db` отдаёт ТОТ ЖЕ объект, что +`get_db` (одна БД, одна транзакция — сегодняшний прод). В режиме `auth` это +физически разные сессии, и `team.py` коммитит их отдельно (`if db is not +identity_db`). Здесь это воспроизводится честно: в режиме `auth` реестр и +продуктовые таблицы получают РАЗНЫЕ `_FakeDB` (общий `_Store` — как общий +«кластер», но разные соединения). + +⚠️ ЛОВУШКА FAKE-DB. `_FakeDB` диспатчит по ТЕКСТУ SQL, а эпик «единый вход» +переименовывает таблицы (`tradein_users`/`tradein_sessions` → `users`/`sessions`) +и меняет тип колонки состояния доступа (`is_active boolean` → `access_state +text`). Литерал «tradein_users» в диспатчере означал бы, что при +`IDENTITY_STORE=auth` ветка молча перестаёт матчиться, fake отдаёт пустоту, а +тест остаётся ЗЕЛЁНЫМ на сломанном коде. Поэтому имена берутся из `sql_names()` +(= `identity_schema()`, тот же словарь, что у продакшн-кода), а непонятый SQL +падает `AssertionError`, а не возвращает пустой результат. + +Значение состояния доступа fake хранит СЫРЫМ (то, что реально лежало бы в +колонке) и НЕ прогоняет через `identity_store.access_state_param()` — иначе +инверсия этой функции прошла бы round-trip через fake незамеченной. +""" + +from __future__ import annotations + +import os +import re +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from uuid import uuid4 + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import auth as auth_router +from app.api.v1 import team as team_router +from app.core import auth as auth_mod +from app.core import config +from app.core.db import get_db +from app.core.password import hash_password +from app.core.rbac import rbac_guard +from app.services.identity_store import AccessState +from tests.support.identity_modes import ( + assert_insert_writes_access_state, + assert_reads_access_state, + assert_update_writes_access_state, + column_value, + patch_identity_sessions, + sql_names, + use_identity_mode, +) + +# --------------------------------------------------------------------------- +# Fake DB backing реестр людей / sessions / quota / user_events +# --------------------------------------------------------------------------- + + +class _Store: + def __init__(self) -> None: + self.users: dict[str, dict[str, Any]] = {} # username -> user dict + self.sessions: dict[str, dict[str, Any]] = {} + self.quota_overrides: dict[str, dict[str, Any]] = {} + self.usage: dict[tuple[str, str], int] = {} + self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields + self.events: list[dict[str, Any]] = [] # user_events rows (history source) + self.sql_log: list[str] = [] # весь SQL, доехавший до «БД» — см. тесты режимов + self.commits: list[int] = [] # id() сессий, на которых вызывали commit() + self._next_id = 1 + self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563) + + def add_user( + self, + username: str, + password_hash: str | None, + *, + role: str = "employee", + manager_id: int | None = None, + access_state: AccessState = AccessState.ACTIVE, + display_name: str | None = None, + org_name: str | None = None, + email: str | None = None, + created_at: datetime | None = None, + ) -> int: + uid = self._next_id + self._next_id += 1 + self.users[username] = { + "id": uid, + "username": username, + "password_hash": password_hash, + "role": role, + "manager_id": manager_id, + "display_name": display_name, + "org_name": org_name, + "email": email, + # СЫРОЕ значение колонки текущего режима (boolean либо text). + "access_state": column_value(access_state), + "created_at": created_at or datetime.now(UTC), + } + return uid + + def user_by_id(self, uid: int) -> dict[str, Any] | None: + for u in self.users.values(): + if u["id"] == uid: + return u + return None + + def add_estimate_event( + self, + username: str, + *, + address: str | None = "ул. Ленина, 1", + area_m2: str | None = "45", + rooms: str | None = "2", + estimate_id: str | None = None, + median_price: int | None = None, + confidence: str | None = None, + n_analogs: int | None = None, + created_at: datetime | None = None, + ) -> None: + eid = estimate_id or str(uuid4()) + self.events.append( + { + "username": username, + "event_type": "estimate_request", + "estimate_id": eid, + "payload": {"address": address, "area_m2": area_m2, "rooms": rooms}, + "created_at": created_at or datetime.now(UTC), + } + ) + if median_price is not None or confidence is not None or n_analogs is not None: + self.estimates[eid] = { + "median_price": median_price, + "confidence": confidence, + "n_analogs": n_analogs, + } + + +class _Result: + """Minimal cursor-result stand-in: `.fetchone()`/`.fetchall()` (attribute-style + Row) AND `.mappings().fetchone()`/`.all()` (dict-style RowMapping) — enough + surface for the SQL used by app.api.v1.team / app.api.v1.auth / + app.services.auth_session / app.services.account_quota.""" + + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def fetchone(self) -> SimpleNamespace | None: + return SimpleNamespace(**self._rows[0]) if self._rows else None + + def fetchall(self) -> list[SimpleNamespace]: + return [SimpleNamespace(**r) for r in self._rows] + + def mappings(self) -> _Mappings: + return _Mappings(self._rows) + + +class _Mappings: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def fetchone(self) -> dict[str, Any] | None: + return dict(self._rows[0]) if self._rows else None + + def all(self) -> list[dict[str, Any]]: + return [dict(r) for r in self._rows] + + +class _FakeDB: + """Minimal Session stand-in dispatching on SQL text — see module docstring.""" + + def __init__(self, store: _Store) -> None: + self.store = store + + def __enter__(self) -> _FakeDB: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def close(self) -> None: + pass + + def commit(self) -> None: + self.store.commits.append(id(self)) + + def rollback(self) -> None: + pass + + def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _Result: + sql = str(stmt) + p = params or {} + s = self.store + s.query_count += 1 + s.sql_log.append(sql) + # Имена таблиц/колонки берутся ИЗ КОДА (identity_schema), а не из + # литералов — см. «ЛОВУШКА FAKE-DB» в модульном docstring. + names = sql_names() + + # ---- сессии реестра ---- + if f"INSERT INTO {names.sessions}" in sql: + now = datetime.now(UTC) + s.sessions[p["token"]] = { + "user_id": p["user_id"], + "expires_at": now + timedelta(hours=p["ttl_hours"]), + "last_seen_at": now, + } + return _Result([]) + + if f"UPDATE {names.sessions}" in sql and "SET last_seen_at" in sql: + sess = s.sessions.get(p["token"]) + if sess is not None: + now = datetime.now(UTC) + sess["last_seen_at"] = now + sess["expires_at"] = now + timedelta(hours=p["ttl_hours"]) + return _Result([]) + + if f"DELETE FROM {names.sessions} WHERE token" in sql: + s.sessions.pop(p["token"], None) + return _Result([]) + + if f"DELETE FROM {names.sessions} WHERE user_id" in sql: + uid = p["user_id"] + for tok in [t for t, sess in s.sessions.items() if sess["user_id"] == uid]: + del s.sessions[tok] + return _Result([]) + + if f"FROM {names.sessions} s" in sql and f"JOIN {names.users} u" in sql: + sess = s.sessions.get(p["token"]) + if sess is None: + return _Result([]) + user = s.user_by_id(sess["user_id"]) + if user is None: + return _Result([]) + # Колонка состояния приезжает под алиасом `access_state` в обоих + # режимах (`u.<колонка> AS access_state`), значение — сырое. + return _Result( + [ + { + "user_id": sess["user_id"], + "expires_at": sess["expires_at"], + "last_seen_at": sess["last_seen_at"], + "username": user["username"], + "role": user["role"], + "display_name": user["display_name"], + "org_name": user["org_name"], + "email": user["email"], + "access_state": user["access_state"], + } + ] + ) + + # ---- реестр: login lookup (get_user_by_username) ---- + # Дискриминатор — bind-параметр `:username` (у pre-check'а уникальности + # ниже он называется `:u`), поэтому ветки не пересекаются ни в одном режиме. + if f"FROM {names.users}" in sql and "WHERE username = :username" in sql: + assert_reads_access_state(sql, names) + user = s.users.get(p["username"]) + return _Result([user] if user is not None else []) + + # ---- реестр: create ---- + if f"INSERT INTO {names.users}" in sql: + assert_insert_writes_access_state(sql, names) + assert_reads_access_state(sql, names) # RETURNING отдаёт её же + uid = s._next_id + s._next_id += 1 + created_at = datetime.now(UTC) + row = { + "id": uid, + "username": p["username"], + "password_hash": p["password_hash"], + "role": "employee", + "manager_id": p["manager_id"], + "display_name": p["display_name"], + "org_name": p["org_name"], + "email": p["email"], + # Ровно то, что код прислал параметром — БЕЗ нормализации. + # Инверсия `access_state_param()` обязана доехать до ответа API + # (`is_active`), а не раствориться в дублёре. + "access_state": p["access_state"], + "created_at": created_at, + } + s.users[p["username"]] = row + return _Result([dict(row)]) + + # ---- реестр: manager_id validation ---- + if f"FROM {names.users}" in sql and "role = 'manager'" in sql: + user = s.user_by_id(p["id"]) + match = user is not None and user["role"] == "manager" + return _Result([{"id": user["id"]}] if match else []) + + # ---- реестр: list managed rows (has explicit ORDER BY) ---- + # Две ветки реального кода: `role = 'employee'` (manager, либо admin с + # ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра — + # ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py). + if ( + f"FROM {names.users}" in sql + and ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) + and "ORDER BY created_at DESC" in sql + ): + assert_reads_access_state(sql, names) + managed = ( + ("employee", "manager") + if "role IN ('employee', 'manager')" in sql + else ("employee",) + ) + rows = [u for u in s.users.values() if u["role"] in managed] + if "manager_id" in p: + rows = [u for u in rows if u["manager_id"] == p["manager_id"]] + # Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak + # is REQUIRED for deterministic paging when created_at ties (follow-up + # review PR #2563 п.1, bulk-seed #2557 inserts many rows in one tx). + rows = sorted(rows, key=lambda u: (u["created_at"], u["id"]), reverse=True) + offset, limit = p.get("offset", 0), p.get("limit", len(rows)) + rows = rows[offset : offset + limit] + return _Result( + [ + { + "id": u["id"], + "username": u["username"], + "role": u["role"], + "display_name": u["display_name"], + "org_name": u["org_name"], + "email": u["email"], + "access_state": u["access_state"], + "manager_id": u["manager_id"], + "created_at": u["created_at"], + } + for u in rows + ] + ) + + # ---- реестр: fetch single managed row by id ---- + if f"FROM {names.users}" in sql and ( + "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql + ): + assert_reads_access_state(sql, names) + managed = ( + ("employee", "manager") + if "role IN ('employee', 'manager')" in sql + else ("employee",) + ) + user = s.user_by_id(p["id"]) + if user is None or user["role"] not in managed: + return _Result([]) + return _Result( + [ + { + "id": user["id"], + "username": user["username"], + "role": user["role"], + "display_name": user["display_name"], + "org_name": user["org_name"], + "email": user["email"], + "access_state": user["access_state"], + "manager_id": user["manager_id"], + "created_at": user["created_at"], + } + ] + ) + + # ---- реестр: uniqueness pre-check ---- + if sql.strip().startswith(f"SELECT id FROM {names.users} WHERE username"): + user = s.users.get(p["u"]) + return _Result([{"id": user["id"]}] if user is not None else []) + + # ---- реестр: update (PATCH) ---- + if f"UPDATE {names.users}" in sql and "SET display_name = COALESCE" in sql: + assert_update_writes_access_state(sql, names) + user = s.user_by_id(p["id"]) + assert user is not None + if p.get("display_name") is not None: + user["display_name"] = p["display_name"] + if p.get("org_name") is not None: + user["org_name"] = p["org_name"] + if p.get("email") is not None: + user["email"] = p["email"] + # COALESCE(CAST(:access_state AS <тип>), <колонка>) — None означает + # «поле не пришло в PATCH», значение записывается КАК ЕСТЬ (см. + # комментарий про round-trip в INSERT выше). + if p.get("access_state") is not None: + user["access_state"] = p["access_state"] + if p.get("password_hash") is not None: + user["password_hash"] = p["password_hash"] + return _Result([]) + + # ---- account_quota_overrides upsert: unlimited always reset to False, + # note preserved (COALESCE) if a row already existed — mirrors real SQL. + if "INSERT INTO account_quota_overrides" in sql: + existing_override = s.quota_overrides.get(p["username"]) + preserved_note = ( + existing_override["note"] if existing_override is not None else None + ) or p["note"] + s.quota_overrides[p["username"]] = { + "monthly_limit": p["monthly_limit"], + "unlimited": False, + "note": preserved_note, + } + return _Result([]) + + # ---- account_quota_overrides: batch (list_employees) ---- + if "SELECT username, monthly_limit, unlimited" in sql: + rows = [ + {"username": u, "monthly_limit": ov["monthly_limit"], "unlimited": ov["unlimited"]} + for u, ov in s.quota_overrides.items() + if u in p["usernames"] + ] + return _Result(rows) + + if "SELECT unlimited FROM account_quota_overrides" in sql: + override = s.quota_overrides.get(p["u"]) + return _Result([{"unlimited": override["unlimited"]}] if override else []) + + if "SELECT monthly_limit FROM account_quota_overrides" in sql: + override = s.quota_overrides.get(p["u"]) + return _Result([{"monthly_limit": override["monthly_limit"]}] if override else []) + + # ---- account_estimate_usage: batch (list_employees) ---- + if "SELECT username, used" in sql: + rows = [ + {"username": u, "used": used} + for (u, period), used in s.usage.items() + if u in p["usernames"] and period == p["period"] + ] + return _Result(rows) + + # ---- account_estimate_usage: single (account_quota.get_status) ---- + if "SELECT used FROM account_estimate_usage" in sql: + used = s.usage.get((p["u"], p["p"])) + return _Result([{"used": used}] if used is not None else []) + + # ---- user_events + trade_in_estimates (history) ---- + if "FROM user_events ue" in sql: + matches = [ + e + for e in s.events + if e["username"] == p["username"] and e["event_type"] == "estimate_request" + ] + matches.sort(key=lambda e: e["created_at"], reverse=True) + page = matches[p["offset"] : p["offset"] + p["limit"]] + rows = [] + for e in page: + extra = s.estimates.get(e["estimate_id"], {}) + rows.append( + { + "estimate_id": e["estimate_id"], + "address": e["payload"].get("address"), + "area_m2": e["payload"].get("area_m2"), + "rooms": e["payload"].get("rooms"), + "median_price": extra.get("median_price"), + "confidence": extra.get("confidence"), + "n_analogs": extra.get("n_analogs"), + "created_at": e["created_at"], + } + ) + return _Result(rows) + + raise AssertionError(f"unhandled fake SQL in test_team_api: {sql!r}") + + +# --------------------------------------------------------------------------- +# Test app +# --------------------------------------------------------------------------- + + +def _build_test_app(store: _Store) -> FastAPI: + app = FastAPI() + app.middleware("http")(rbac_guard) + app.include_router(auth_router.router, prefix="/api/v1/auth", tags=["auth"]) + app.include_router(team_router.router, prefix="/api/v1/team", tags=["team"]) + + def _override_get_db(): # generator dependency — matches app.core.db.get_db shape + yield _FakeDB(store) + + app.dependency_overrides[get_db] = _override_get_db + return app + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + auth_mod.reset_cache_for_tests() + auth_router._LOGIN_LIMITER._hits.clear() + monkeypatch.setattr(config.settings, "auth_mode", "dual") + # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод). + use_identity_mode(monkeypatch, "tradein") + # team.py / auth.py events go through schedule_event (own SessionLocal(), fire- + # and-forget) — captured into a list instead of hitting a real DB. + monkeypatch.setattr(team_router, "schedule_event", lambda **kw: _EVENTS.append(kw)) + monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: None) + _EVENTS.clear() + + +_EVENTS: list[dict[str, Any]] = [] + + +@pytest.fixture +def store() -> _Store: + return _Store() + + +@pytest.fixture +def auth_store(store: _Store, monkeypatch: pytest.MonkeyPatch) -> _Store: + """Тот же `store`, но реестр — БД `auth` (`users`/`sessions`, text-состояние). + + Запрашивать ПЕРЕД `client`: `store.add_user` фиксирует значение колонки по + режиму на момент вызова. + """ + use_identity_mode(monkeypatch, "auth") + return store + + +@pytest.fixture +def client(store: _Store, monkeypatch: pytest.MonkeyPatch) -> TestClient: + # Подменяем сессию РЕЕСТРА на обоих её источниках сразу, а не ветвление по + # режиму: `identity_session()` / `get_identity_db()` остаются настоящими, + # включая инвариант «в дефолтном режиме это тот же объект, что у get_db». + patch_identity_sessions(monkeypatch, lambda: _FakeDB(store)) + # base_url=https:// — login sets a Secure cookie; see test_auth_api.py for why + # a plain-http TestClient would silently drop it. + return TestClient(_build_test_app(store), base_url="https://testserver") + + +def _login(client: TestClient, username: str, password: str) -> None: + resp = client.post("/api/v1/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + + +# --------------------------------------------------------------------------- +# POST /employees — happy path + validation +# --------------------------------------------------------------------------- + + +def test_manager_creates_employee_forces_own_manager_id(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_alice", "password": "Secret123!", "display_name": "Алиса"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["username"] == "emp_alice" + assert body["manager_id"] == mgr_id + assert body["quota"]["limit"] > 0 + assert store.users["emp_alice"]["password_hash"] is not None + assert any(e["event_type"] == "employee_created" for e in _EVENTS) + created_event = next(e for e in _EVENTS if e["event_type"] == "employee_created") + # Пароль никогда не попадает в аудит-событие. + assert "Secret123!" not in str(created_event) + + +def test_admin_creates_employee_with_explicit_manager_id(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + _login(client, "admin1", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_bob", "password": "Secret123!", "manager_id": mgr_id}, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["manager_id"] == mgr_id + + +def test_admin_creates_employee_without_manager_id(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + _login(client, "admin1", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_free", "password": "Secret123!"} + ) + assert resp.status_code == 201, resp.text + assert resp.json()["manager_id"] is None + + +def test_admin_create_employee_invalid_manager_id_422(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + _login(client, "admin1", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_x", "password": "Secret123!", "manager_id": 999}, + ) + assert resp.status_code == 422 + + +def test_create_employee_non_ascii_username_422(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "сотрудник", "password": "Secret123!"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.parametrize("username", ["admin\n", "user1\n"]) +def test_create_employee_trailing_newline_username_422_not_500( + client: TestClient, store: _Store, username: str +) -> None: + """Deep-review seed #2564: Python `$` matches BEFORE a trailing newline + (`re.match(r'...\\$', 'admin\\n')` → True), but Postgres `~` (CHECK + tradein_users_username_ascii_ck, migration 193) does NOT — a username with a + trailing "\\n" used to pass Pydantic validation and crash in the DB (500) + instead of a clean 422. `_USERNAME_RE` now uses `\\Z`, matching Postgres `~` + semantics exactly.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": username, "password": "Secret123!"}, + ) + assert resp.status_code == 422, resp.text + + +def test_create_employee_duplicate_username_409(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("emp_dup", hash_password("Secret123!"), role="employee") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_dup", "password": "Secret123!"}, + ) + assert resp.status_code == 409 + + +def test_create_employee_no_session_401(client: TestClient) -> None: + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} + ) + assert resp.status_code == 401 + + +def test_create_employee_employee_role_403(client: TestClient, store: _Store) -> None: + store.add_user("emp_a", hash_password("Secret123!"), role="employee") + _login(client, "emp_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} + ) + assert resp.status_code == 403 + + +def test_employee_role_403_on_all_team_routes(client: TestClient, store: _Store) -> None: + store.add_user("emp_a", hash_password("Secret123!"), role="employee") + _login(client, "emp_a", "Secret123!") + + assert client.get("/api/v1/team/employees").status_code == 403 + assert client.patch("/api/v1/team/employees/1", json={}).status_code == 403 + assert client.get("/api/v1/team/employees/1/history").status_code == 403 + + +# --------------------------------------------------------------------------- +# Admin управляет менеджерами (инцидент 2026-07-31: kopylov/praktika — role +# 'manager', сбросить им пароль через UI было нечем) +# --------------------------------------------------------------------------- + + +def test_admin_list_includes_managers(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + by_username = {e["username"]: e for e in resp.json()} + # Менеджер виден; сам admin — нет (role='admin' не отдаётся никогда). + assert set(by_username) == {"mgr_a", "emp_a"} + assert by_username["mgr_a"]["role"] == "manager" + assert by_username["emp_a"]["role"] == "employee" + + +def test_admin_resets_manager_password_and_revokes_sessions( + client: TestClient, store: _Store +) -> None: + """Главный сценарий инцидента: admin выдаёт менеджеру новый пароль.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("OldSecret1!"), role="manager") + + # У менеджера есть живая сессия — после сброса она обязана умереть. + _login(client, "mgr_a", "OldSecret1!") + assert any(sess["user_id"] == mgr_id for sess in store.sessions.values()) + client.cookies.clear() + + _login(client, "admin1", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"new_password": "NewSecret1!"}) + assert resp.status_code == 200 + assert resp.json()["role"] == "manager" + assert not any(sess["user_id"] == mgr_id for sess in store.sessions.values()) + + # Новый пароль реально работает, старый — нет. + client.cookies.clear() + assert ( + client.post( + "/api/v1/auth/login", json={"username": "mgr_a", "password": "OldSecret1!"} + ).status_code + == 401 + ) + assert ( + client.post( + "/api/v1/auth/login", json={"username": "mgr_a", "password": "NewSecret1!"} + ).status_code + == 200 + ) + + +def test_admin_blocks_manager(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + + _login(client, "admin1", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"is_active": False}) + assert resp.status_code == 200 + assert resp.json()["is_active"] is False + + +def test_manager_cannot_patch_another_manager_404(client: TestClient, store: _Store) -> None: + """Расширение прав дано ТОЛЬКО admin'у — manager до чужой строки не достаёт.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_b_id}", json={"new_password": "Hacked123!"}) + assert resp.status_code == 404 + # И в списке чужого менеджера тоже нет. + assert client.get("/api/v1/team/employees").json() == [] + + +def test_admin_cannot_patch_admin_row_404(client: TestClient, store: _Store) -> None: + """Инвариант отсутствия self-lockout: строки role='admin' недостижимы.""" + admin_id = store.add_user("admin1", hash_password("Secret123!"), role="admin") + other_admin_id = store.add_user("admin2", hash_password("Secret123!"), role="admin") + + _login(client, "admin1", "Secret123!") + assert ( + client.patch(f"/api/v1/team/employees/{admin_id}", json={"is_active": False}).status_code + == 404 + ) + assert ( + client.patch( + f"/api/v1/team/employees/{other_admin_id}", json={"new_password": "Nope12345!"} + ).status_code + == 404 + ) + + +def test_manager_cannot_see_managers_in_own_list(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + + _login(client, "mgr_a", "Secret123!") + rows = client.get("/api/v1/team/employees").json() + assert {e["username"] for e in rows} == {"emp_a"} + assert {e["role"] for e in rows} == {"employee"} + + +# --------------------------------------------------------------------------- +# Org isolation — manager A vs manager B +# --------------------------------------------------------------------------- + + +def test_manager_a_cannot_see_manager_b_employee(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + store.add_user("emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b) + + _login(client, "mgr_a", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + usernames = {e["username"] for e in resp.json()} + assert usernames == {"emp_a"} + + +def test_manager_a_patch_manager_b_employee_404(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + emp_b_id = store.add_user( + "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b + ) + assert mgr_a # used only to seed manager_id != mgr_b + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{emp_b_id}", json={"display_name": "hacked"}) + assert resp.status_code == 404 + + +def test_manager_a_history_manager_b_employee_404(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + emp_b_id = store.add_user( + "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b + ) + assert mgr_a + + _login(client, "mgr_a", "Secret123!") + resp = client.get(f"/api/v1/team/employees/{emp_b_id}/history") + assert resp.status_code == 404 + + +def test_manager_post_with_foreign_manager_id_creates_under_self( + client: TestClient, store: _Store +) -> None: + """POST с чужим manager_id в теле от лица manager — ИГНОРИРУЕТСЯ, сотрудник + создаётся под ЕГО собственным manager_id, не под указанным чужим.""" + mgr_a_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + assert mgr_a_id != mgr_b_id + + _login(client, "mgr_a", "Secret123!") + resp = client.post( + "/api/v1/team/employees", + json={ + "username": "emp_spoof", + "password": "Secret123!", + "manager_id": mgr_b_id, + }, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["manager_id"] == mgr_a_id + assert store.users["emp_spoof"]["manager_id"] == mgr_a_id + + +# --------------------------------------------------------------------------- +# PATCH /employees/{id} — block/unblock/quota/profile/password +# --------------------------------------------------------------------------- + + +def test_block_employee_revokes_sessions(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("EmpSecret1!"), role="employee", manager_id=mgr_id + ) + + # Employee logs in first — real session created via the real auth flow. + emp_client = TestClient(client.app, base_url="https://testserver") + _login(emp_client, "emp_a", "EmpSecret1!") + assert any(sess["user_id"] == emp_id for sess in store.sessions.values()) + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) + assert resp.status_code == 200, resp.text + assert resp.json()["is_active"] is False + + # Blocking must have revoked ALL of emp_a's sessions. + assert not any(sess["user_id"] == emp_id for sess in store.sessions.values()) + assert any(e["event_type"] == "employee_blocked" for e in _EVENTS) + + +def test_reset_password_revokes_old_sessions(client: TestClient, store: _Store) -> None: + """HIGH (deep-review PR #2563): смена пароля обязана ревокать ВСЕ существующие + сессии сотрудника — иначе скомпрометированная/чужая сессия переживает reset + (sliding-refresh в auth_session.py продлевает её бесконечно, а не «до TTL»).""" + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id + ) + + emp_client = TestClient(client.app, base_url="https://testserver") + _login(emp_client, "emp_a", "OldSecret1!") + assert any(sess["user_id"] == emp_id for sess in store.sessions.values()) + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"}) + assert resp.status_code == 200, resp.text + + # Password reset must have revoked ALL of emp_a's pre-existing sessions — + # not just when is_active is explicitly set to False. + assert not any(sess["user_id"] == emp_id for sess in store.sessions.values()) + + +def test_unblock_employee_event(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", + hash_password("Secret123!"), + role="employee", + manager_id=mgr_id, + access_state=AccessState.DISABLED, + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True}) + assert resp.status_code == 200 + assert resp.json()["is_active"] is True + assert any(e["event_type"] == "employee_unblocked" for e in _EVENTS) + + +def test_patch_monthly_limit_reflected_in_quota_status(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"monthly_limit": 30}) + assert resp.status_code == 200, resp.text + assert resp.json()["quota"]["limit"] == 30 + assert any(e["event_type"] == "quota_changed" for e in _EVENTS) + + listing = client.get("/api/v1/team/employees") + assert listing.status_code == 200 + entry = next(e for e in listing.json() if e["id"] == emp_id) + assert entry["quota"]["limit"] == 30 + + +def test_patch_reset_password_no_password_in_events(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"}) + assert resp.status_code == 200, resp.text + assert any(e["event_type"] == "employee_password_reset" for e in _EVENTS) + assert "NewSecret2!" not in str(_EVENTS) + + emp_client = TestClient(client.app, base_url="https://testserver") + login_resp = emp_client.post( + "/api/v1/auth/login", json={"username": "emp_a", "password": "NewSecret2!"} + ) + assert login_resp.status_code == 200 + + +def test_patch_no_session_401(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /employees — list + admin filter +# --------------------------------------------------------------------------- + + +def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + store.add_user("emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b) + + _login(client, "admin1", "Secret123!") + resp_all = client.get("/api/v1/team/employees") + assert resp_all.status_code == 200 + # Без фильтра admin видит и менеджеров — иначе им нечем сбросить пароль + # (инцидент 2026-07-31). Сам admin в выдачу не попадает. + assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b", "mgr_a", "mgr_b"} + + # ?manager_id= — по-прежнему ТОЛЬКО сотрудники этого менеджера. + resp_filtered = client.get("/api/v1/team/employees", params={"manager_id": mgr_a}) + assert resp_filtered.status_code == 200 + assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} + + +def test_list_employees_query_count_is_not_n_plus_1(client: TestClient, store: _Store) -> None: + """Medium2 (deep-review PR #2563): pre-fix measured 2N+3 = 23 SQL queries for + N=10 employees (per-employee `account_quota.get_status`). Batch quota lookup + (`_batch_quota_status`) must keep the query count constant regardless of N.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + for i in range(10): + store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + store.query_count = 0 # reset after login's own DB traffic + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + assert len(resp.json()) == 11 # 10 сотрудников + mgr_a (admin видит менеджеров) + # rbac_guard session lookup (1) + current_team_actor session lookup (1) + + # list query (1) + 2 batch quota queries = 5, flat regardless of N. + assert store.query_count <= 5, f"expected O(1) queries for N=10, got {store.query_count}" + + +def test_list_employees_pagination(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + for i in range(5): + store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": 1}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees", params={"limit": 500}) + assert resp.status_code == 422 + + +def test_list_employees_pagination_stable_with_identical_created_at( + client: TestClient, store: _Store +) -> None: + """Follow-up review PR #2563 п.1: `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, + bulk-seed (#2557) вставляет много юзеров одной транзакцией → идентичный + timestamp у N+ строк. Без `id DESC` тай-брейкера порядок между страницами + на PostgreSQL для строк-«близнецов» не гарантирован — сотрудники пропадали/ + дублировались бы при постраничном листании. Вставляем 5 сотрудников с + ОДИНАКОВЫМ created_at, листаем limit=2 постранично — объединение страниц + обязано дать полный набор без дублей и пропусков.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + same_ts = datetime.now(UTC) + # Менеджер тоже в выдаче admin'а (см. test_admin_list_includes_managers) — + # он такая же строка для пейджинга, тай-брейкер обязан покрывать и её. + expected_usernames = {"mgr_a"} + for i in range(5): + username = f"emp_tie_{i}" + store.add_user( + username, + hash_password("Secret123!"), + role="employee", + manager_id=mgr_id, + created_at=same_ts, + ) + expected_usernames.add(username) + + _login(client, "admin1", "Secret123!") + + seen: list[str] = [] + offset = 0 + while True: + resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": offset}) + assert resp.status_code == 200, resp.text + page = [e["username"] for e in resp.json()] + if not page: + break + seen.extend(page) + offset += 2 + + assert len(seen) == len(expected_usernames), ( + f"page union has {len(seen)} entries (dupes or gaps), expected " + f"{len(expected_usernames)}: {seen}" + ) + assert set(seen) == expected_usernames + + +# --------------------------------------------------------------------------- +# CSRF defense-in-depth — Origin/Referer check on state-changing team routes +# --------------------------------------------------------------------------- + + +def test_create_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_evil", "password": "Secret123!"}, + headers={"Origin": "https://evil.example"}, + ) + assert resp.status_code == 403 + assert "emp_evil" not in store.users + + +def test_create_employee_origin_allowed(client: TestClient, store: _Store) -> None: + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + allowed_origin = config.settings.cors_origins[0] + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_ok", "password": "Secret123!"}, + headers={"Origin": allowed_origin}, + ) + assert resp.status_code == 201, resp.text + + +def test_create_employee_no_origin_no_referer_allowed(client: TestClient, store: _Store) -> None: + """curl-смоук внутри контейнера не шлёт ни Origin, ни Referer — не должен ломаться.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_curl", "password": "Secret123!"}, + ) + assert resp.status_code == 201, resp.text + + +def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.patch( + f"/api/v1/team/employees/{emp_id}", + json={"display_name": "hacked"}, + headers={"Referer": "https://evil.example/csrf.html"}, + ) + assert resp.status_code == 403 + assert store.users["emp_a"]["display_name"] != "hacked" + + +# --------------------------------------------------------------------------- +# _batch_quota_status unlimited semantics — must match account_quota.is_unlimited +# --------------------------------------------------------------------------- + + +def test_batch_quota_unlimited_ignored_for_non_roles_yaml_username( + client: TestClient, store: _Store +) -> None: + """Follow-up review PR #2563 п.2: `account_quota.is_unlimited` short-circuits + to False for a username NOT in roles.yaml — it never even reads + `account_quota_overrides.unlimited`. The batch quota status used by + GET /employees must agree, or the list would show "unlimited" for a quota + that real enforcement (check_and_raise/increment, same is_unlimited) does + NOT honor — a misleading display. `emp_ghost_unlimited` is a fresh DB-only + username guaranteed absent from roles.yaml.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user( + "emp_ghost_unlimited", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + store.quota_overrides["emp_ghost_unlimited"] = { + "monthly_limit": 15, + "unlimited": True, + "note": "manual grant via SQL runbook (not through team-api)", + } + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200, resp.text + entry = next(e for e in resp.json() if e["username"] == "emp_ghost_unlimited") + # DB override says unlimited=true, but username is NOT in roles.yaml — real + # enforcement would never see it, so the list must NOT claim "unlimited". + assert entry["quota"]["unlimited"] is False + assert entry["quota"]["limit"] == 15 + + +def test_batch_quota_unlimited_honored_for_roles_yaml_username( + client: TestClient, store: _Store +) -> None: + """Symmetric positive case: a username actually present in roles.yaml + (non-admin role) — `account_quota_overrides.unlimited=true` IS honored, same + as `account_quota.is_unlimited`. Uses `kopylov` — real prod pilot-role entry + in auth/roles.yaml (see app/core/auth.py module docstring).""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("kopylov", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + store.quota_overrides["kopylov"] = { + "monthly_limit": 999, + "unlimited": True, + "note": "existing prod grant", + } + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200, resp.text + entry = next(e for e in resp.json() if e["username"] == "kopylov") + assert entry["quota"]["unlimited"] is True + + +# --------------------------------------------------------------------------- +# GET /employees/{id}/history +# --------------------------------------------------------------------------- + + +def test_employee_history_happy_path(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + now = datetime.now(UTC) + store.add_estimate_event( + "emp_a", + address="ул. Малышева, 10", + median_price=6_500_000, + confidence="high", + n_analogs=12, + created_at=now - timedelta(minutes=5), + ) + store.add_estimate_event("emp_a", address="ул. Мамина-Сибиряка, 5", created_at=now) + + _login(client, "mgr_a", "Secret123!") + resp = client.get(f"/api/v1/team/employees/{emp_id}/history") + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body) == 2 + assert body[0]["address"] == "ул. Мамина-Сибиряка, 5" # most recent first + priced = next(e for e in body if e["address"] == "ул. Малышева, 10") + assert priced["median_price"] == 6_500_000 + assert priced["confidence"] == "high" + + +def test_employee_history_pagination(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + for i in range(5): + store.add_estimate_event("emp_a", address=f"адрес-{i}") + + _login(client, "mgr_a", "Secret123!") + resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 2, "offset": 1}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +def test_employee_history_limit_max_200(client: TestClient, store: _Store) -> None: + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + _login(client, "mgr_a", "Secret123!") + + resp = client.get(f"/api/v1/team/employees/{emp_id}/history", params={"limit": 500}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# Эпик «единый вход»: режим IDENTITY_STORE=auth (общий реестр в БД `auth`). +# +# Всё выше идёт в ДЕФОЛТНОМ режиме — он же прод. Ниже — то, что появляется +# только после переезда: другая БД под реестром (две сессии вместо одной) и +# текстовое трёхзначное состояние доступа вместо булева `is_active`. +# --------------------------------------------------------------------------- + + +def test_default_mode_single_session_and_tradein_tables(client: TestClient, store: _Store) -> None: + """Дефолт: реестр и продуктовые таблицы — ОДНА сессия, один commit, старые имена. + + Это и есть «после мержа прод работает точно как сейчас» на уровне + транзакции: «сотрудник создан, квота нет» невозможно, потому что писать + обоих некуда, кроме одной транзакции. + """ + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + store.commits.clear() + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7}, + ) + assert resp.status_code == 201, resp.text + + # Ровно один commit и ровно на одной сессии — `db is identity_db`. + assert len(set(store.commits)) == 1, store.commits + joined = "\n".join(store.sql_log) + assert "tradein_users" in joined + assert "tradein_sessions" in joined + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+users\b", joined) + assert not re.search(r"\b(FROM|INTO|UPDATE|JOIN)\s+sessions\b", joined) + # Новый сотрудник заводится открытым — булевым литералом, как и раньше. + assert store.users["emp_x"]["access_state"] is True + assert resp.json()["is_active"] is True + + +def test_auth_mode_commits_registry_and_product_db_separately( + auth_store: _Store, client: TestClient +) -> None: + """Режим `auth`: БД физически две → две сессии и два отдельных коммита. + + Порядок несущий (реестр первым): не доехавшая квота — это сотрудник с + глобальным лимитом (чинится повторным PATCH), а обратный порядок оставил бы + висящий override на несуществующего человека. + """ + auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + auth_store.commits.clear() + + resp = client.post( + "/api/v1/team/employees", + json={"username": "emp_x", "password": "Secret123!", "monthly_limit": 7}, + ) + assert resp.status_code == 201, resp.text + + assert len(set(auth_store.commits)) == 2, auth_store.commits + joined = "\n".join(auth_store.sql_log) + assert "tradein_users" not in joined + assert "tradein_sessions" not in joined + assert re.search(r"INSERT INTO\s+users\b", joined) + # Квота осталась в ПРОДУКТОВОЙ таблице — она в общий реестр не переезжает. + assert "INSERT INTO account_quota_overrides" in joined + assert auth_store.quota_overrides["emp_x"]["monthly_limit"] == 7 + + +def test_auth_mode_create_writes_text_active_literal( + auth_store: _Store, client: TestClient +) -> None: + """INSERT кладёт в колонку 'active' (text), а не булев true. + + Значение fake хранит как есть — если бы `access_state_param()` инвертировался + или отдавал не тот тип, это доехало бы прямо сюда и до `is_active` в ответе. + """ + auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + _login(client, "mgr_a", "Secret123!") + + resp = client.post( + "/api/v1/team/employees", json={"username": "emp_x", "password": "Secret123!"} + ) + + assert resp.status_code == 201, resp.text + assert auth_store.users["emp_x"]["access_state"] == "active" + assert resp.json()["is_active"] is True + + +def test_auth_mode_block_writes_disabled_and_revokes_sessions( + auth_store: _Store, client: TestClient +) -> None: + """PATCH is_active=false → колонка 'disabled' + все сессии сотрудника порваны. + + Сессии живут в БД РЕЕСТРА, поэтому рвать их надо через `identity_db`: с + продуктовой сессией DELETE ушёл бы не в ту БД, и блокировка не действовала бы + до истечения TTL (а sliding-refresh продлевал бы её бесконечно). + """ + mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = auth_store.add_user( + "emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id + ) + auth_store.sessions["emp-token"] = { + "user_id": emp_id, + "expires_at": datetime.now(UTC) + timedelta(hours=1), + "last_seen_at": datetime.now(UTC), + } + _login(client, "mgr_a", "Secret123!") + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": False}) + + assert resp.status_code == 200, resp.text + assert resp.json()["is_active"] is False + assert auth_store.users["emp_a"]["access_state"] == "disabled" + assert "emp-token" not in auth_store.sessions + + +def test_auth_mode_trial_expired_shows_as_blocked_and_unblock_activates( + auth_store: _Store, client: TestClient +) -> None: + """`trial_expired` в «Команде» выглядит заблокированным, а is_active=true снимает + пробное ограничение (переводит в `active`). + + Форма ответа API не меняется этим PR: `is_active` остаётся булевым и считается + как «пустят ли входить». Отдельное отображение пробного периода — вопрос UI-PR'а. + """ + mgr_id = auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + emp_id = auth_store.add_user( + "emp_a", + hash_password("Secret123!"), + role="employee", + manager_id=mgr_id, + access_state=AccessState.TRIAL_EXPIRED, + ) + _login(client, "mgr_a", "Secret123!") + + listed = client.get("/api/v1/team/employees") + assert listed.status_code == 200, listed.text + assert [e["is_active"] for e in listed.json()] == [False] + + resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"is_active": True}) + assert resp.status_code == 200, resp.text + assert resp.json()["is_active"] is True + assert auth_store.users["emp_a"]["access_state"] == "active" + + +def test_auth_mode_org_isolation_still_404s_foreign_employee( + auth_store: _Store, client: TestClient +) -> None: + """Главный инвариант «Команды» (чужой сотрудник → 404, не 403) переезд переживает.""" + auth_store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = auth_store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + foreign_id = auth_store.add_user( + "emp_b", hash_password("Secret123!"), role="employee", manager_id=mgr_b_id + ) + _login(client, "mgr_a", "Secret123!") + + assert client.get("/api/v1/team/employees").json() == [] + patched = client.patch(f"/api/v1/team/employees/{foreign_id}", json={"is_active": False}) + assert patched.status_code == 404 + assert client.get(f"/api/v1/team/employees/{foreign_id}/history").status_code == 404 + # Чужая строка не тронута. + assert auth_store.users["emp_b"]["access_state"] == "active" + + +def test_auth_mode_employee_role_still_403_on_team_routes( + auth_store: _Store, client: TestClient +) -> None: + """Роль резолвится из общего реестра — employee по-прежнему не админ «Команды».""" + auth_store.add_user("emp_only", hash_password("Secret123!"), role="employee") + _login(client, "emp_only", "Secret123!") + + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 403 + assert "admin or manager" in resp.json()["detail"].lower() diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py index 262fc372..373ab2b0 100644 --- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -76,14 +76,30 @@ def _db_mock(rows: list[dict[str, Any]]) -> MagicMock: return db -def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]: - """Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip).""" +# #2581: subject lat/lon — совпадают с _row()'s hardcoded lat/lon (56.838/60.595), +# т.е. subject и comps в одной точке ЕКБ → ST_DWithin(ANCHOR_TIER_A_RADIUS_M) +# тривиально проходит, не мешая #1774 novostroyki-gating semantics ниже. +_LAT = 56.838 +_LON = 60.595 + + +def _fetch( + db: MagicMock, *, lat: float | None = None, lon: float | None = None +) -> tuple[list[dict[str, Any]], str | None]: + """Вызов _fetch_anchor_comps с Tier A-релевантными аргументами. + + lat/lon по умолчанию None — тесты, ожидающие tier=None (novostroyki-gate + отбраковал все comps до min_comps), передают None намеренно: с #2581 + геогейтом Tier A целиком пропускается без lat/lon (см. Tier C — тот же + паттерн), что и раньше давало tier=None (Tier C тоже требовал lat/lon). + Тесты, ожидающие tier=='A', передают _LAT/_LON явно. + """ return _fetch_anchor_comps( db, address=_ADDRESS, target_house_id=None, - lat=None, - lon=None, + lat=lat, + lon=lon, rooms=2, area=50.0, ) @@ -106,7 +122,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None: ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): - comps, tier = _fetch(db) + comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. assert len(comps) == 6 @@ -178,7 +194,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None: ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): - comps, tier = _fetch(db) + comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. assert len(comps) == 5 @@ -214,7 +230,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None: ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): - comps, tier = _fetch(db) + comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # source_id-primary схлопывает несмотря на разные url → 5 comps. assert len(comps) == 5 @@ -233,7 +249,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None: ] db = _db_mock(rows) with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): - comps, tier = _fetch(db) + comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. assert len(comps) == 4 diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py index 3bc90192..44d6ac26 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -28,6 +28,19 @@ from scraper_kit.providers.yandex.valuation import ( from app.services.estimator import _save_yandex_history_items +def _history_rows(db) -> list[dict]: + """Строки батча house_placement_history из мока сессии. + + #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция + сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama + перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + """ + for call in db.execute.call_args_list: + if "INSERT INTO house_placement_history" in str(call.args[0]): + return call.args[1] + return [] + + def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult: return YandexValuationResult( address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106", @@ -84,7 +97,7 @@ def test_item_with_area_none_is_skipped() -> None: saved = _save_yandex_history_items(db, result) assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}" - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert len(rows) == 1 assert rows[0]["area"] == 50.0 @@ -109,7 +122,7 @@ def test_item_with_area_zero_is_skipped() -> None: saved = _save_yandex_history_items(db, result) assert saved == 1 - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["area"] == 55.0 @@ -155,8 +168,11 @@ def test_all_invalid_area_returns_zero_no_crash() -> None: assert saved == 0 # db.execute не должен вызываться для пустого rows (нет INSERT) - # Commit вызывается, rollback — нет. - db.commit.assert_called_once() + assert _history_rows(db) == [] + # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись + # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась + # ли история по площади. + assert db.commit.call_count == 2 db.rollback.assert_not_called() @@ -200,7 +216,7 @@ def test_mixed_items_only_valid_saved() -> None: saved = _save_yandex_history_items(db, result) assert saved == 2 - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert len(rows) == 2 areas = {r["area"] for r in rows} assert areas == {40.0, 60.0} diff --git a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py index 72e3d07c..f5e413fc 100644 --- a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py +++ b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py @@ -20,8 +20,6 @@ from scraper_kit.providers.yandex.valuation import YandexValuationScraper _KIT_CONFIG = SimpleNamespace( scraper_proxy_url=None, - yandex_proxy_rotate_url=None, - avito_proxy_rotate_url=None, ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index 1e34b15d..21d6f605 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -25,8 +25,6 @@ from scraper_kit.providers.yandex.valuation import ( _KIT_CONFIG = SimpleNamespace( scraper_proxy_url=None, - yandex_proxy_rotate_url=None, - avito_proxy_rotate_url=None, ) # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 9079f0df..308d4cb1 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -26,6 +26,19 @@ from scraper_kit.providers.yandex.valuation import ( from app.services.estimator import _save_yandex_history_items +def _history_rows(db) -> list[dict]: + """Строки батча house_placement_history из мока сессии. + + #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция + сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama + перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + """ + for call in db.execute.call_args_list: + if "INSERT INTO house_placement_history" in str(call.args[0]): + return call.args[1] + return [] + + def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult: return YandexValuationResult( address="Россия, Свердловская область, Екатеринбург, улица Куйбышева, 106", @@ -90,9 +103,8 @@ def test_save_row_contains_house_id_and_confidence(): ): _save_yandex_history_items(db, result) - # db.execute called once with list-of-dicts (executemany, one round-trip) - assert db.execute.call_count == 1 - rows = db.execute.call_args_list[0].args[1] + # История — один execute со list-of-dicts (executemany, один round-trip). + rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 for row in rows: assert row["house_id"] == 54321 @@ -125,7 +137,7 @@ def test_save_row_contains_removed_date(): _save_yandex_history_items(db, result) # args[1] is now the list-of-dicts passed to executemany - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["removed_date"] == date(2024, 5, 20) @@ -152,7 +164,7 @@ def test_save_row_removed_date_none_when_active(): ): _save_yandex_history_items(db, result) - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["removed_date"] is None @@ -180,7 +192,7 @@ def test_save_handles_match_failure_gracefully(): saved = _save_yandex_history_items(db, result) assert saved == 1 - rows = db.execute.call_args_list[0].args[1] + rows = _history_rows(db) assert rows[0]["house_id"] is None assert rows[0]["confidence"] == pytest.approx(0.0) assert rows[0]["notes"] is None diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index d03b95f0..ed630a90 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -19,9 +19,10 @@ Per-provider модель (#1793): Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый - инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет - geoip=True — фингерпринт привязан к гео конкретного прокси, поэтому смешивать - источники в одном браузере нельзя. + инстанс имеет geoip=True — фингерпринт привязан к гео прокси, поэтому смешивать + источники в одном браузере нельзя. Egress-прокси сейчас общий для всех + поставщиков (SCRAPER_PROXY_URL, #2616 шаг 2) — раздельные instance'ы остаются + ради geoip-изоляции fingerprint'а и per-provider concurrency (ниже), не прокси. Конкурентность: * МЕЖДУ поставщиками — параллельно (avito/cian/yandex гоняются одновременно); @@ -46,6 +47,9 @@ Per-provider модель (#1793): для гидрации, CSS проверяется anti-bot fingerprint. (legacy alias BROWSER_BLOCKED_RESOURCE_TYPES всё ещё читается как fallback, если новая переменная не задана.) + ⚠ BROWSER_BLOCK_RESOURCES (булев выключатель до #1812) + НЕ читается — см. _RETIRED_ENV; в проде он всё ещё + выставлен во всех трёх контейнерах и ни на что не влияет. BROWSER_MIN_PAGE_INTERVAL_S — минимальный интервал (сек) между последовательными page.goto ОДНОГО провайдера (default: 2.0). Даёт под- коннектам предыдущей страницы дренироваться, прежде чем @@ -59,14 +63,19 @@ Per-provider модель (#1793): глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat: при незаданных per-provider env поведение идентично предыдущему (чисто аддитивный override). - BROWSER_PROXY_AVITO — http-прокси для avito (fallback AVITO_PROXY_URL). - BROWSER_PROXY_CIAN — http-прокси для cian (fallback CIAN_PROXY_URL). - BROWSER_PROXY_YANDEX — http-прокси для yandex (fallback YANDEX_PROXY_URL). - SCRAPER_PROXY_URL — http-прокси для generic (fallback AVITO_PROXY_URL). + SCRAPER_PROXY_URL — http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/ + yandex/generic). #2616 шаг 2: per-provider + BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy + AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — + все указывали на закрытые mobileproxy-аккаунты + (407/connection refused, проверено вживую #2613). - ВАЖНО: для браузера используем именно BROWSER_PROXY_* (http-прокси). Legacy - CIAN_PROXY_URL/YANDEX_PROXY_URL могут быть socks5 с авторизацией — playwright - их не умеет, поэтому они лишь fallback (если кто-то положил http туда же). + ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт). + #2616 шаг 1: прод + нет НИ override в теле, НИ + SCRAPER_PROXY_URL → отказ (503, явная причина), а НЕ + launch camoufox без proxy (= прямое подключение с IP + сервера). В dev отсутствие прокси легитимно (см. + _no_live_proxy). Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS): pre_click_selectors — список селекторов для последовательного клика до формы; @@ -90,6 +99,12 @@ logger = logging.getLogger(__name__) # ── конфигурация из env ──────────────────────────────────────────────────────── +# #2616 шаг 1: признак окружения. "production" в прод-контейнерах (ENV: ENVIRONMENT, +# см. app.core.config.Settings.environment в основном backend). Дефолт "dev" — как и +# везде в проекте (Settings.environment: str = "dev"). +ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev") +IS_PROD: bool = ENVIRONMENT == "production" + BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) BROWSER_RECYCLE_PAGES: int = int(os.environ.get("BROWSER_RECYCLE_PAGES", "15")) BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000")) @@ -110,6 +125,11 @@ FETCH_JSON_SETTLE_MS: int = int(os.environ.get("FETCH_JSON_SETTLE_MS", "1200")) FETCH_JSON_INPAGE_RETRIES: int = int(os.environ.get("FETCH_JSON_INPAGE_RETRIES", "1")) # Пауза между in-page попытками fetch(), мс. FETCH_JSON_RETRY_DELAY_MS: int = int(os.environ.get("FETCH_JSON_RETRY_DELAY_MS", "800")) +# Сколько ждать события `load` на ПОВТОРЕ после гонки «execution context destroyed» +# (#2676). Только на повторе: happy-path остаётся на дешёвом FETCH_JSON_SETTLE_MS, +# иначе бесконечно дозагружающаяся страница удлиняла бы КАЖДЫЙ запрос. Ожидание +# best-effort — по таймауту всё равно пробуем evaluate. +FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15000")) # Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий # инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод. @@ -211,20 +231,30 @@ def _provider_for_url(url: str) -> str: def _provider_proxy(provider: str) -> str | None: - """Возвращает http-прокси для поставщика из env (с фолбэком), иначе None. + """Возвращает http-прокси для поставщика из env, иначе None. - Используем BROWSER_PROXY_* как primary — это http-прокси для браузера. - Legacy *_PROXY_URL — лишь fallback: playwright НЕ умеет socks5 с авторизацией, - поэтому socks5-варианты CIAN/YANDEX как primary не годятся. + #2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy + AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на + закрытые mobileproxy-аккаунты (#2613). SCRAPER_PROXY_URL (ASocks, http-прокси, + playwright-совместимый) — единственный источник, общий для ВСЕХ поставщиков + (включая generic). `provider` оставлен параметром сигнатуры (вызывающие + передают его) — на случай возврата к per-provider egress в будущем. """ - env_map: dict[str, tuple[str, str]] = { - "avito": ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"), - "cian": ("BROWSER_PROXY_CIAN", "CIAN_PROXY_URL"), - "yandex": ("BROWSER_PROXY_YANDEX", "YANDEX_PROXY_URL"), - "generic": ("SCRAPER_PROXY_URL", "AVITO_PROXY_URL"), - } - primary, fallback = env_map.get(provider, env_map["generic"]) - return os.environ.get(primary) or os.environ.get(fallback) + return os.environ.get("SCRAPER_PROXY_URL") + + +def _no_live_proxy(provider: str, proxy_override: str | None) -> bool: + """True если нет НИ explicit proxy в теле запроса, НИ SCRAPER_PROXY_URL. + + #2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси + легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue + пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси" + здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный + случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана + но мертва (407/connection refused), этот guard не триггерит — за живость значения + отвечает мониторинг прокси-пула, не этот код. + """ + return proxy_override is None and _provider_proxy(provider) is None def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None: @@ -514,6 +544,36 @@ async def _on_startup(app: web.Application) -> None: list(PROVIDERS), ) logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) + _warn_retired_env() + + +# Переменные, которые окружение всё ещё задаёт, а код больше не читает. Ставим сюда +# ровно те, что реально видели в проде — молчаливо игнорируемая ручка опаснее +# отсутствующей: оператор ставит BROWSER_BLOCK_RESOURCES=false, чтобы посмотреть +# страницу с ресурсами, ничего не меняется, и он делает вывод не о переменной, +# а о блокировке. +_RETIRED_ENV: dict[str, str] = { + # #1812 заменил булев выключатель на список типов. Блокировка при этом НЕ + # ослабла: image глушится camoufox block_images (_launch_browser), font/media — + # дефолтом BROWSER_BLOCK_RESOURCE_TYPES. Мёртв только сам выключатель. + "BROWSER_BLOCK_RESOURCES": ( + "BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)" + ), +} + + +def _warn_retired_env() -> None: + """Предупреждает про заданные env, которые код больше не читает (#2674).""" + for name, replacement in _RETIRED_ENV.items(): + if os.environ.get(name) is not None: + logger.warning( + "tradein-browser: %s=%r задана, но НЕ читается кодом с #1812 — " + "значение ни на что не влияет. Замена: %s. Уберите переменную из " + "окружения, чтобы она не выглядела рабочим выключателем", + name, + os.environ[name], + replacement, + ) async def _on_cleanup(app: web.Application) -> None: @@ -548,9 +608,9 @@ def _resolve_provider(body: dict, url: str) -> str: берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS; иначе host-detect; иначе generic. - # domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod - # → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect - # domclick.ru → generic.) + # domclick (source='domklik') нет в PROVIDERS → fallthrough на host-detect + # domclick.ru → generic. Намеренно (все поставщики делят один SCRAPER_PROXY_URL + # с #2616 шаг 2, generic-путь эквивалентен). """ explicit = body.get("provider") or body.get("source") if isinstance(explicit, str) and explicit in PROVIDERS: @@ -564,7 +624,7 @@ def _resolve_proxy_override(body: dict, provider: str) -> str | None: Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело ``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` — прокси, взятый из пула через acquire(source). Если поля нет / пусто → None → инстанс поставщика берёт - свой env-прокси (BROWSER_PROXY_*), поведение не меняется (ship-dark parity). + общий SCRAPER_PROXY_URL (#2616 шаг 2), поведение не меняется (ship-dark parity). camoufox НЕ умеет socks5 с авторизацией → принимаем ТОЛЬКО http/https-прокси; иной proxy_kind игнорируем (→ None, env-fallback) с предупреждением. Отсутствие proxy_kind @@ -627,6 +687,18 @@ async def fetch_handler(request: web.Request) -> web.Response: provider = _resolve_provider(body, url) proxy_override = _resolve_proxy_override(body, provider) + # #2616 шаг 1: прод + нет НИ override, НИ env-прокси → явный отказ ДО лока/launch'а, + # а не тихий заход camoufox напрямую с IP сервера (dev это легитимно, см. _no_live_proxy). + if IS_PROD and _no_live_proxy(provider, proxy_override): + logger.warning( + "tradein-browser[%s]: /fetch отказ — нет прокси в теле и нет env-прокси " + "(prod) — не подключаемся напрямую с IP сервера (#2616)", + provider, + ) + return web.json_response( + {"error": "no proxy configured — refusing direct connection (prod)"}, status=503 + ) + lock = await _lock_for(provider) async with lock: # Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка — @@ -693,6 +765,17 @@ async def fetch_json_handler(request: web.Request) -> web.Response: provider = _resolve_provider(body, url) proxy_override = _resolve_proxy_override(body, provider) + # #2616 шаг 1: см. fetch_handler — прод + нет прокси вообще → отказ, не direct-IP. + if IS_PROD and _no_live_proxy(provider, proxy_override): + logger.warning( + "tradein-browser[%s]: /fetch-json отказ — нет прокси в теле и нет env-прокси " + "(prod) — не подключаемся напрямую с IP сервера (#2616)", + provider, + ) + return web.json_response( + {"error": "no proxy configured — refusing direct connection (prod)"}, status=503 + ) + lock = await _lock_for(provider) async with lock: # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). @@ -922,9 +1005,54 @@ async def _do_fetch_json( return await _fetch_json_once( provider, url, method=method, headers=headers, body=body, origin=origin ) + # #2676 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же + # странице). Повторять здесь, на свежей странице, бесполезно: пересоздание + # воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08, + # обе попытки упали одинаково. raise +# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917: +# первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы +# (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) — +# это успешный resp, НЕ ретраим (решает caller). +_IN_PAGE_FETCH_JS = """async ({url, method, headers, body, retries, retryDelayMs}) => { + let lastErr; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const resp = await fetch(url, { + method: method || 'GET', + headers: headers || {}, + body: (body !== null && body !== undefined) ? body : undefined, + credentials: 'include', + }); + const text = await resp.text(); + return { status: resp.status, body: text, attempts: attempt + 1 }; + } catch (e) { + lastErr = e; + if (attempt < retries) { + await new Promise(r => setTimeout(r, retryDelayMs)); + } + } + } + throw lastErr; +}""" + + +async def _wait_for_load_best_effort(page: object, provider: str) -> None: + """Ждёт `load`, но не даёт ожиданию сорвать саму попытку (реклама/трекеры могут + держать страницу «загружающейся» бесконечно). Таймаут логируется, не пробрасывается. + """ + try: + await page.wait_for_load_state("load", timeout=FETCH_JSON_LOAD_WAIT_MS) # type: ignore[attr-defined] + except Exception as exc: + logger.info( + "tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть", + provider, + type(exc).__name__, + ) + + async def _fetch_json_once( provider: str, url: str, @@ -956,41 +1084,34 @@ async def _fetch_json_once( # (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась # перед in-page fetch'ем. await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] - # In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), - # #1917: первый XHR после навигации иногда падает до готовности стека. Повтор - # внутри страницы (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). - # HTTP-статусы (4xx/5xx) — это успешный resp, НЕ ретраим (решает caller). - result: dict = await page.evaluate( # type: ignore[attr-defined] - """async ({url, method, headers, body, retries, retryDelayMs}) => { - let lastErr; - for (let attempt = 0; attempt <= retries; attempt++) { - try { - const resp = await fetch(url, { - method: method || 'GET', - headers: headers || {}, - body: (body !== null && body !== undefined) ? body : undefined, - credentials: 'include', - }); - const text = await resp.text(); - return { status: resp.status, body: text, attempts: attempt + 1 }; - } catch (e) { - lastErr = e; - if (attempt < retries) { - await new Promise(r => setTimeout(r, retryDelayMs)); - } - } - } - throw lastErr; - }""", - { - "url": url, - "method": method, - "headers": headers or {}, - "body": body, - "retries": FETCH_JSON_INPAGE_RETRIES, - "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, - }, - ) + payload = { + "url": url, + "method": method, + "headers": headers or {}, + "body": body, + "retries": FETCH_JSON_INPAGE_RETRIES, + "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, + } + try: + result: dict = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] + except Exception as exc: + if not _is_page_context_lost(exc): + raise + # #2676, живой прод 06.08: страница origin уходит в клиентскую навигацию + # ПОСЛЕ load, поэтому ни settle, ни ожидание load её не опережают, а повтор + # с новой страницей воспроизводит ту же первую навигацию (проверено на + # проде: две попытки подряд — та же ошибка). Навигация при этом ОДНА: + # к моменту исключения она уже случилась и у страницы НОВЫЙ контекст. + # Поэтому повторяем evaluate на ЭТОЙ же странице — стоит миллисекунды. + logger.warning( + "tradein-browser[%s]: контекст страницы пересоздан навигацией, " + "повтор evaluate на той же странице: %s", + provider, + url, + ) + await _wait_for_load_best_effort(page, provider) + await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] + result = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] finally: await page.close() # type: ignore[attr-defined] @@ -1028,6 +1149,23 @@ def _is_browser_crash(exc: BaseException) -> bool: ) +# Литерал playwright, а не строка из нашего лога: driver 1.60.0 (в образе сайдкара) +# бросает ровно «Execution context was destroyed» / «... , most likely because of a +# navigation.» — обе формы начинаются одинаково, поэтому хватает одного маркера. +# Проверено grep'ом по playwright/driver/package/lib/coreBundle.js в живом контейнере. +_PAGE_CONTEXT_LOST_MARKER = "execution context was destroyed" + + +def _is_page_context_lost(exc: BaseException) -> bool: + """Страница потеряла JS-контекст (ушла в навигацию между goto и evaluate), #2676. + + НЕ краш браузера: инстанс жив, потеряна одна страница. Поэтому обрабатывается + отдельно от _is_browser_crash — relaunch здесь стоил бы ~10-20с и тёплый профиль + (cookies/фингерпринт инстанса) ради браузера, с которым всё в порядке. + """ + return _PAGE_CONTEXT_LOST_MARKER in str(exc).lower() + + # ── login handler ────────────────────────────────────────────────────────────── @@ -1116,6 +1254,18 @@ async def login_handler(request: web.Request) -> web.Response: provider = _resolve_provider(body, body["url"]) + # #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) → + # отказ, не direct-IP. + if IS_PROD and _no_live_proxy(provider, None): + logger.warning( + "tradein-browser[%s]: /login отказ — нет env-прокси (prod) — " + "не подключаемся напрямую с IP сервера (#2616)", + provider, + ) + return web.json_response( + {"error": "no proxy configured — refusing direct connection (prod)"}, status=503 + ) + lock = await _lock_for(provider) async with lock: # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 6c5d5642..2f1438eb 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -2,7 +2,8 @@ Проверяет ядро per-provider модели: 1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic); - 2. _provider_proxy — BROWSER_PROXY_* primary с фолбэком на legacy *_PROXY_URL; + 2. _provider_proxy — SCRAPER_PROXY_URL, общий для всех поставщиков (#2616 шаг 2: + per-provider BROWSER_PROXY_*/legacy *_PROXY_URL сняты); 3. _resolve_provider — явный body["provider"] перебивает host, иначе host; 4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи), два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и @@ -79,57 +80,21 @@ def test_provider_for_url_generic_fallback() -> None: # ── _provider_proxy ────────────────────────────────────────────────────────────── -def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None: - """BROWSER_PROXY_* — primary источник для каждого поставщика.""" - monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080") - monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080") - monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080") - assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080" - assert server._provider_proxy("cian") == "http://u:p@cian-proxy:8080" - assert server._provider_proxy("yandex") == "http://u:p@yandex-proxy:8080" - - -def test_provider_proxy_falls_back_to_legacy(monkeypatch: pytest.MonkeyPatch) -> None: - """Без BROWSER_PROXY_* берётся legacy *_PROXY_URL фолбэк.""" - monkeypatch.delenv("BROWSER_PROXY_AVITO", raising=False) - monkeypatch.delenv("BROWSER_PROXY_CIAN", raising=False) - monkeypatch.delenv("BROWSER_PROXY_YANDEX", raising=False) - monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@legacy-avito:8080") - monkeypatch.setenv("CIAN_PROXY_URL", "http://u:p@legacy-cian:8080") - monkeypatch.setenv("YANDEX_PROXY_URL", "http://u:p@legacy-yandex:8080") - assert server._provider_proxy("avito") == "http://u:p@legacy-avito:8080" - assert server._provider_proxy("cian") == "http://u:p@legacy-cian:8080" - assert server._provider_proxy("yandex") == "http://u:p@legacy-yandex:8080" - - -def test_provider_proxy_generic(monkeypatch: pytest.MonkeyPatch) -> None: - """generic: SCRAPER_PROXY_URL primary, AVITO_PROXY_URL фолбэк.""" - monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) - monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@avito:8080") - assert server._provider_proxy("generic") == "http://u:p@avito:8080" - monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@scraper:8080") - assert server._provider_proxy("generic") == "http://u:p@scraper:8080" +def test_provider_proxy_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None: + """#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех.""" + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080") + assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080" + assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080" + assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080" + assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080" def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: - """Ни одной env → None (direct, как одиночный браузер без прокси).""" - for var in ( - "BROWSER_PROXY_CIAN", - "CIAN_PROXY_URL", - ): - monkeypatch.delenv(var, raising=False) + """SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси).""" + monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) assert server._provider_proxy("cian") is None -def test_provider_proxy_browser_proxy_wins_over_legacy( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """BROWSER_PROXY_* перебивает legacy *_PROXY_URL (http vs возможный socks5).""" - monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@http-cian:8080") - monkeypatch.setenv("CIAN_PROXY_URL", "socks5://u:p@socks-cian:1080") - assert server._provider_proxy("cian") == "http://u:p@http-cian:8080" - - # ── _resolve_provider ──────────────────────────────────────────────────────────── @@ -158,8 +123,8 @@ def test_resolve_provider_uses_source_field() -> None: def test_resolve_provider_domclick_source_to_generic() -> None: """domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru. - 'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: в prod - нет выделенного BROWSER_PROXY_DOMCLICK. + 'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: 'domklik' + не входит в PROVIDERS (нет выделенного instance). """ assert ( server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic" @@ -407,7 +372,11 @@ def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyP def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: """Первый goto провайдера — без ожидания, но timestamp пишется.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + # Интервал задаём в _MIN_PAGE_INTERVAL_BY_PROVIDER — ИМЕННО его читает + # _pace_provider (per-provider override, #1812 follow-up). Модульный + # BROWSER_MIN_PAGE_INTERVAL_S там только фолбэк для провайдера вне словаря, + # так что патч глобала на известном провайдере не влияет ни на что. + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -421,7 +390,7 @@ def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> N def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None: """Второй goto в пределах интервала → sleep на остаток (interval - elapsed).""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -450,7 +419,7 @@ def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None: """Если с прошлого goto прошло >= интервала → не спим.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "yandex", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -475,8 +444,8 @@ def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.Monkey def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None: - """BROWSER_MIN_PAGE_INTERVAL_S <= 0 → пейсинг выключен, timestamp не пишется.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) + """Эффективный интервал провайдера <= 0 → пейсинг выключен, timestamp не пишется.""" + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 0.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -495,7 +464,8 @@ def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> No def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None: """Timestamp независим по провайдерам — пейсинг avito не влияет на cian.""" - monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0) + monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0) slept: list[float] = [] async def _fake_sleep(d: float) -> None: @@ -520,5 +490,39 @@ def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) assert slept == [], "разные провайдеры не пейсят друг друга" +# ── #2674: отставные переменные окружения ──────────────────────────────────── +# BROWSER_BLOCK_RESOURCES стоит =true во всех трёх прод-контейнерах, а код перестал +# её читать в #1812 (булев выключатель → BROWSER_BLOCK_RESOURCE_TYPES). Блокировка при +# этом не ослабла (image глушит camoufox block_images, font/media — дефолт списка), но +# сама ручка мертва: выставив =false, оператор ничего не выключит и сделает вывод о +# блокировке, а не о переменной. Сервис обязан сказать это вслух на старте. + + +def test_retired_env_warns_when_set( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") + with caplog.at_level("WARNING"): + server._warn_retired_env() + assert any("BROWSER_BLOCK_RESOURCES" in r.getMessage() for r in caplog.records), ( + "мёртвая переменная выставлена, а сервис молчит" + ) + + +def test_retired_env_silent_when_unset( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.delenv("BROWSER_BLOCK_RESOURCES", raising=False) + with caplog.at_level("WARNING"): + server._warn_retired_env() + assert caplog.records == [], "предупреждение без повода — это шум, а не сигнал" + + +def test_retired_env_does_not_change_blocking(monkeypatch: pytest.MonkeyPatch) -> None: + """Отставная переменная остаётся отставной: блокировкой правит только список типов.""" + monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") + assert server._parse_block_types(None) == frozenset({"font", "media"}) + + async def _coro(value: Any) -> Any: return value diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index 0af86622..3e841cf4 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -67,6 +67,7 @@ class _FakePage: def __init__(self, evaluate_result: dict[str, Any]) -> None: self.goto_urls: list[str] = [] self.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917 + self.load_waits: list[int] = [] # wait_for_load_state("load", timeout=) — #2676 self.closed = 0 # evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы. self.evaluate = AsyncMock(return_value=evaluate_result) @@ -80,6 +81,9 @@ class _FakePage: async def wait_for_timeout(self, ms: int) -> None: self.waits.append(ms) + async def wait_for_load_state(self, state: str, timeout: int = 0) -> None: + self.load_waits.append(timeout) + async def close(self) -> None: self.closed += 1 @@ -365,3 +369,247 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch healthy_page.evaluate.assert_awaited_once() assert crashing_page.closed == 1 assert healthy_page.closed == 1 + + +# ── гонка «execution context was destroyed» (#2676) ─────────────────────────────── + + +def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.MonkeyPatch) -> None: + """#2676 (живой прод 06.08): навигация УЖЕ случилась → повтор evaluate на той же странице. + + Ключевое отличие от внешнего ретрая: страница НЕ пересоздаётся. Пересоздание + воспроизводит ту же первую навигацию origin'а (проверено на проде: обе попытки + падали одинаково), а после исключения у страницы уже новый, стабильный контекст. + """ + page = _FakePage({"status": 200, "body": "ok"}) + page.evaluate = AsyncMock( + side_effect=[ + RuntimeError("Page.evaluate: Execution context was destroyed, " + "most likely because of a navigation."), + {"status": 200, "body": '{"recovered": true}'}, + ] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) + + result = asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + + assert result == {"status": 200, "body": '{"recovered": true}'} + assert page.evaluate.await_count == 2 + # Ни новой страницы, ни повторной навигации — только ожидание конца текущей. + assert browser.opened == 1 + assert page.goto_urls == ["https://www.avito.ru/"] + assert page.load_waits == [4242] + assert page.closed == 1 + + +def test_fetch_json_same_page_retry_happens_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Повтор evaluate ровно один: вторая та же ошибка уезжает наверх (там внешняя ветка).""" + message = "Execution context was destroyed" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="Execution context was destroyed"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert page.evaluate.await_count == 2 + assert page.closed == 1 + + +def test_fetch_json_no_same_page_retry_for_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Чужая ошибка evaluate не даёт второго прохода — повтор стоит денег.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError("boom")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + page.evaluate.assert_awaited_once() + + +def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Подменяет _relaunch_browser счётчиком — тест падает, если его всё-таки позвали.""" + calls: list[str] = [] + + async def _fake(provider: str) -> None: + calls.append(provider) + + monkeypatch.setattr(server, "_relaunch_browser", _fake) + return calls + + +# Обе формы, которые бросает playwright 1.60 (driver coreBundle.js) — короткая и полная. +@pytest.mark.parametrize( + "message", + [ + "Page.evaluate: Execution context was destroyed, most likely because of a navigation.", + "Execution context was destroyed", + ], +) +def test_do_fetch_json_recovers_from_destroyed_context( + monkeypatch: pytest.MonkeyPatch, message: str +) -> None: + """#2676: обе формы сообщения playwright распознаются и переживаются повтором. + + До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash + матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500. + """ + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError(message), {"status": 200, "body": '{"recovered": true}'}] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + relaunched = _no_relaunch(monkeypatch) + + result = asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + + assert result == {"status": 200, "body": '{"recovered": true}'} + # Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с). + assert relaunched == [] + # И новой страницы тоже нет: повтор идёт по уже перешедшему контексту. + assert browser.opened == 1 + assert page.evaluate.await_count == 2 + + +def test_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + """Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError("Execution context was destroyed"), {"status": 200, "body": "ok"}] + ) + page.wait_for_load_state = AsyncMock( # type: ignore[method-assign] + side_effect=TimeoutError("Timeout 15000ms exceeded") + ) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + result = asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert result == {"status": 200, "body": "ok"} + + +def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.MonkeyPatch) -> None: + """Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл. + + Свежей страницы тут намеренно НЕТ: пересоздание воспроизводит ту же первую + навигацию origin'а (прод 06.08 — обе попытки упали одинаково), а страховкой + остаётся один повтор всего запроса на стороне клиента (BrowserFetcher). + """ + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=RuntimeError("Page.evaluate: Execution context was destroyed") + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + with pytest.raises(RuntimeError, match="Execution context was destroyed"): + asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert page.evaluate.await_count == 2 + assert browser.opened == 1 + + +def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + with pytest.raises(RuntimeError, match="ERR_PROXY_CONNECTION_FAILED"): + asyncio.run( + server._do_fetch_json( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + page.evaluate.assert_awaited_once() + + +def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.MonkeyPatch) -> None: + """Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708).""" + message = "Execution context was destroyed" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + _no_relaunch(monkeypatch) + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + response = asyncio.run( + server.fetch_json_handler( + _make_request({"url": "https://www.avito.ru/api/x", "source": "avito"}) + ) + ) + assert response.status == 500 + assert "Execution context was destroyed" in _json_body(response)["error"] diff --git a/tradein-mvp/browser/test_server_no_proxy_refusal.py b/tradein-mvp/browser/test_server_no_proxy_refusal.py new file mode 100644 index 00000000..b74321ec --- /dev/null +++ b/tradein-mvp/browser/test_server_no_proxy_refusal.py @@ -0,0 +1,301 @@ +"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера. + +tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело +запроса, ни SCRAPER_PROXY_URL не несли proxy, лениво запускал camoufox БЕЗ +proxy — прямое подключение с IP сервера. #2616 шаг 2 удалил мёртвые per-provider +переменные (AVITO_PROXY_URL/BROWSER_PROXY_* и т.д., 407/connection refused, +#2613) — SCRAPER_PROXY_URL остался единственным источником. Этот файл фиксирует +контракт (`_no_live_proxy` + `IS_PROD` guard в +fetch_handler/fetch_json_handler/login_handler). + +Проверяет: + 1. `_no_live_proxy` — pure-function поведение (override / env приоритеты). + 2. prod + нет override + нет env → 503 с распознаваемой причиной, `_ensure_browser` + (и, соответственно, `_launch_browser`) НЕ вызывается вовсе — camoufox не + стартует без прокси. + 3. dev (или явный IS_PROD=False) + то же самое → прежнее поведение (lazy launch + без прокси легитимен для локальной разработки). + 4. prod + proxy присутствует (override ИЛИ env) → guard не триггерит, поведение + без изменений (это НЕ случай отказа). + 5. Текст отказа отличим от "browser unavailable (proxy may be down)" (launch + failed на мёртвом-но-заданном прокси) — разные причины, разный текст. + +camoufox НЕ запускается: `_ensure_browser` мокается (см. test_server_proxy_override.py). +Хендлер вызывается напрямую (make_mocked_request не поднимает сокет). + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_no_proxy_refusal.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path +from typing import Any + +import pytest +from aiohttp.test_utils import make_mocked_request + +_SERVER_PATH = Path(__file__).resolve().parent / "server.py" +_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) +assert _spec is not None and _spec.loader is not None +server = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(server) + + +def _json_body(response: Any) -> dict[str, Any]: + return json.loads(response.body.decode()) + + +async def _coro(value: Any) -> Any: + return value + + +def _make_request(path: str, body: dict[str, Any]) -> Any: + request = make_mocked_request("POST", path) + request.json = lambda: _coro(body) # type: ignore[method-assign] + return request + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + """Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера. + + Явно чистим SCRAPER_PROXY_URL (единственная переменная, которую `_provider_proxy` + читает, #2616 шаг 2) — тест не должен зависеть от того, что реально задано в + shell/CI-раннере. + """ + monkeypatch.setattr(server, "_browsers", {}) + monkeypatch.setattr(server, "_browser_cms", {}) + monkeypatch.setattr(server, "_page_counters", {}) + monkeypatch.setattr(server, "_locks", {}) + monkeypatch.setattr(server, "_retry_tasks", {}) + monkeypatch.setattr(server, "_last_goto_at", {}) + monkeypatch.setattr(server, "_launched_proxy", {}) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) + + +def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Мок `_ensure_browser`, падающий AssertionError на любом вызове — falsification: + если guard регрессирует (перестаёт отказывать ДО lock/launch), тест ловит это + несовпадением типа исключения, а не просто другим статус-кодом. + """ + calls: list[str] = [] + + async def _boom(provider: str, proxy_override: str | None = None) -> bool: + calls.append(provider) + raise AssertionError("_ensure_browser must NOT be called — no proxy configured, prod") + + monkeypatch.setattr(server, "_ensure_browser", _boom) + return calls + + +# ── _no_live_proxy: pure-function поведение ───────────────────────────────────── + + +def test_no_live_proxy_true_without_override_and_env() -> None: + assert server._no_live_proxy("avito", None) is True + + +def test_no_live_proxy_false_with_override() -> None: + assert server._no_live_proxy("avito", "http://pool:8080") is False + + +def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") + assert server._no_live_proxy("avito", None) is False + + +# ── fetch_handler: prod-отказ, никакого launch'а ──────────────────────────────── + + +def test_fetch_handler_prod_refuses_without_launch_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(server, "IS_PROD", True) + calls = _forbid_ensure_browser(monkeypatch) + + response = asyncio.run( + server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"})) + ) + + assert response.status == 503 + assert calls == [] # _ensure_browser НЕ вызывался вовсе + body = _json_body(response) + assert "no proxy configured" in body["error"] + + +def test_fetch_json_handler_prod_refuses_without_launch_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(server, "IS_PROD", True) + calls = _forbid_ensure_browser(monkeypatch) + + response = asyncio.run( + server.fetch_json_handler( + _make_request("/fetch-json", {"url": "https://www.avito.ru/api/x", "source": "avito"}) + ) + ) + + assert response.status == 503 + assert calls == [] + body = _json_body(response) + assert "no proxy configured" in body["error"] + + +def test_login_handler_prod_refuses_without_launch_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(server, "IS_PROD", True) + calls = _forbid_ensure_browser(monkeypatch) + + response = asyncio.run( + server.login_handler( + _make_request( + "/login", + { + "url": "https://www.cian.ru/login", + "email": "a@b.ru", + "password": "x", + "email_selector": "#e", + "password_selector": "#p", + "submit_selector": "#s", + "success_cookie": "sid", + }, + ) + ) + ) + + assert response.status == 503 + assert calls == [] + body = _json_body(response) + assert "no proxy configured" in body["error"] + + +# ── dev: прежнее поведение (lazy launch без прокси легитимен) ────────────────── + + +def test_fetch_handler_dev_still_attempts_launch(monkeypatch: pytest.MonkeyPatch) -> None: + """IS_PROD=False (dev) + нет прокси вообще → guard НЕ триггерит, _ensure_browser + вызывается как раньше (dev/no-op direct connection — легитимно для локалки). + """ + monkeypatch.setattr(server, "IS_PROD", False) + calls: list[tuple[str, str | None]] = [] + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + calls.append((provider, proxy_override)) + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + server._browsers["avito"] = object() # чтобы дальше не падать на _do_fetch + + async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str: + return "" + + monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) + + response = asyncio.run( + server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"})) + ) + + assert response.status == 200 + assert calls == [("avito", None)] # launch БЫЛ атакован — dev-путь не блокирует + + +# ── prod + proxy присутствует (override ИЛИ env) → guard не триггерит ────────── + + +def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server, "IS_PROD", True) + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") # "живой" с т.з. guard'а + calls: list[tuple[str, str | None]] = [] + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + calls.append((provider, proxy_override)) + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + server._browsers["avito"] = object() + + async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str: + return "" + + monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) + + response = asyncio.run( + server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"})) + ) + + assert response.status == 200 + assert calls == [("avito", None)] # guard не заблокировал — env-прокси "есть" + + +def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server, "IS_PROD", True) + calls: list[tuple[str, str | None]] = [] + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + calls.append((provider, proxy_override)) + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + server._browsers["avito"] = object() + + async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str: + return "" + + monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) + + response = asyncio.run( + server.fetch_handler( + _make_request( + "/fetch", + { + "url": "https://www.avito.ru/x", + "proxy": "http://pool:8080", + "proxy_kind": "http", + }, + ) + ) + ) + + assert response.status == 200 + assert calls == [("avito", "http://pool:8080")] + + +# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ──────── + + +def test_refusal_message_distinguishable_from_launch_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """"нет прокси вообще" (наша инфраструктура) — другой текст, чем "browser + unavailable (proxy may be down)" (launch упал на мёртвом-но-заданном прокси). + """ + monkeypatch.setattr(server, "IS_PROD", True) + _forbid_ensure_browser(monkeypatch) + + no_proxy_resp = asyncio.run( + server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"})) + ) + no_proxy_text = _json_body(no_proxy_resp)["error"] + + # launch failure (дохлый-но-заданный env) — существующий, другой путь. + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://dead:8080") + + async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool: + return False # camoufox launch упал (InvalidProxy) — существующая семантика + + monkeypatch.setattr(server, "_ensure_browser", _fails_launch) + launch_failed_resp = asyncio.run( + server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"})) + ) + launch_failed_text = _json_body(launch_failed_resp)["error"] + + assert no_proxy_text != launch_failed_text + assert "no proxy configured" in no_proxy_text + assert "browser unavailable" in launch_failed_text diff --git a/tradein-mvp/browser/test_server_proxy_override.py b/tradein-mvp/browser/test_server_proxy_override.py index 301402ab..2ec4fdd4 100644 --- a/tradein-mvp/browser/test_server_proxy_override.py +++ b/tradein-mvp/browser/test_server_proxy_override.py @@ -126,8 +126,7 @@ def test_ensure_browser_changed_proxy_relaunches(monkeypatch: pytest.MonkeyPatch def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None: """proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity).""" - for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"): - monkeypatch.setenv(var, "http://env:8080") + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") calls = _install_fake_launch(monkeypatch) async def _run() -> None: @@ -141,8 +140,7 @@ def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.Monkey def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch) -> None: """env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена).""" - for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"): - monkeypatch.setenv(var, "http://env:8080") + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") calls = _install_fake_launch(monkeypatch) async def _run() -> None: diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index 43208a73..13c8527d 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -89,7 +89,7 @@ def test_fetch_returns_503_when_browser_unavailable( Никакого реального camoufox-launch'а / direct-fetch. """ - async def _no_ensure(provider: str) -> bool: + async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool: return False # прокси всё ещё недоступен → браузер остаётся None monkeypatch.setattr(server, "_ensure_browser", _no_ensure) @@ -111,7 +111,7 @@ def test_login_returns_503_when_browser_unavailable( ) -> None: """/login тоже отдаёт 503 когда инстанс не поднят (без credential-логирования).""" - async def _no_ensure(provider: str) -> bool: + async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool: return False monkeypatch.setattr(server, "_ensure_browser", _no_ensure) @@ -193,7 +193,7 @@ def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> Non server._browsers["avito"] = _FakeBrowser(tracker) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) - async def _ensure(provider: str) -> bool: + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_ensure_browser", _ensure) @@ -221,7 +221,7 @@ def test_fetch_recycles_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None relaunched["n"] += 1 server._page_counters[provider] = 0 - async def _ensure(provider: str) -> bool: + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 4d346c26..bb1e073d 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -67,6 +67,10 @@ services: # Сколько /fetch параллельно. Дефолт 4; ops поднимет до 8 в .env.runtime после смоука. BROWSER_CONCURRENCY: ${BROWSER_CONCURRENCY:-4} # SCRAPER_PROXY_URL читается из .env.runtime (см. env_file выше) + # #2616 шаг 1: server.py IS_PROD-guard (отказ 503 вместо direct-IP camoufox при + # отсутствии прокси) активен только при ENVIRONMENT=production — без этой + # строки guard молча спит (дефолт "dev"). + ENVIRONMENT: production expose: - "3000" restart: unless-stopped diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json index b3579017..f71653ba 100644 --- a/tradein-mvp/frontend/package.json +++ b/tradein-mvp/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs" }, "dependencies": { "@tanstack/react-query": "^5.50.0", diff --git a/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs b/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs new file mode 100644 index 00000000..f0612f25 --- /dev/null +++ b/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Guard for issue #2631 / #2545: публичный B2C-лэндинг `/mera-public` должен + * оставаться "пустым по зависимостям" — ни один файл, который Next.js грузит + * СТАТИЧЕСКИ (initial JS для этого route-чанка), не должен транзитивно + * дотягиваться до закрытого контура (useMe / lib/api / lib/sessionId / + * isPathAllowed / RBAC). Никакого test runner (jest/vitest) в проекте нет + * (см. package.json) — это source-level guard вместо test-suite. + * + * НЕ подключён в `.forgejo/workflows/ci-tradein.yml` этим PR (вне scope — + * только `tradein-mvp/frontend/**`). Чтобы он реально гейтил PR, нужен один + * доп. `run: npm run check:mera-public-isolation` шаг в job `frontend-checks` + * рядом с `npm run lint` — отдельным devops/frontend PR. + * + * Как это соотносится с реальным webpack-чанком: `RouteGuard.tsx` намеренно + * подключает `GuardedRoute` через `next/dynamic(() => import(...))` — то есть + * точку разрыва графа модулей (см. шапки RouteGuard.tsx / GuardedRoute.tsx). + * Этот скрипт следует ТОЛЬКО статическим edges (`import ... from "..."` / + * `export ... from "..."` / side-effect `import "..."`) и НИКОГДА не идёт + * внутрь динамического `import(...)` — ровно то же правило, по которому + * webpack решает, что попадёт в один чанк, а что уедет в отдельный. + * + * Запуск: `node scripts/check-mera-public-isolation.mjs` (npm run + * check:mera-public-isolation). Exit 1 + печать цепочки импортов при находке. + */ + +import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SRC = path.resolve(__dirname, "..", "src"); + +const RESOLVABLE_EXTS = [".ts", ".tsx", ".js", ".jsx"]; +const STATIC_IMPORT_RE = /(?:^|\s)(?:import|export)\s[^;]*?\sfrom\s*["']([^"']+)["']|(?:^|\s)import\s*["']([^"']+)["']/g; + +function collectFilesRecursively(dir) { + if (!existsSync(dir)) return []; + const out = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + out.push(...collectFilesRecursively(full)); + } else if (RESOLVABLE_EXTS.includes(path.extname(full))) { + out.push(full); + } + } + return out; +} + +// Всё, что Next.js грузит для /mera-public НЕЗАВИСИМО от dynamic()-разрывов: +// корневой layout (Providers + RouteGuard) + всё поддерево лэндинга. +const ENTRY_FILES = [ + path.join(SRC, "app/layout.tsx"), + path.join(SRC, "app/providers.tsx"), + path.join(SRC, "components/auth/RouteGuard.tsx"), + ...collectFilesRecursively(path.join(SRC, "app/mera-public")), +]; + +// Закрытый контур — см. GuardedRoute.tsx шапку. GuardedRoute.tsx сам тоже +// forbidden: он обязан доезжать ТОЛЬКО через next/dynamic, никогда статически. +const FORBIDDEN = [ + "lib/useMe.ts", + "lib/api.ts", + "lib/sessionId.ts", + "lib/isPathAllowed.ts", + "components/auth/GuardedRoute.tsx", +].map((p) => path.join(SRC, p)); + +function resolveSpecifier(spec, fromFile) { + let base; + if (spec.startsWith("@/")) { + base = path.join(SRC, spec.slice(2)); + } else if (spec.startsWith(".")) { + base = path.resolve(path.dirname(fromFile), spec); + } else { + return null; // bare specifier — внешний пакет / next/*, не наш граф + } + + if (existsSync(base) && statSync(base).isFile()) return base; + for (const ext of RESOLVABLE_EXTS) { + if (existsSync(base + ext)) return base + ext; + } + for (const ext of RESOLVABLE_EXTS) { + const indexed = path.join(base, "index" + ext); + if (existsSync(indexed)) return indexed; + } + return null; // .css / .module.css / картинки / не найдено — лист, не ошибка +} + +function staticImportsOf(file) { + const src = readFileSync(file, "utf8"); + const specs = []; + let m; + STATIC_IMPORT_RE.lastIndex = 0; + while ((m = STATIC_IMPORT_RE.exec(src)) !== null) { + specs.push(m[1] ?? m[2]); + } + return specs; +} + +// BFS от всех entry-файлов по СТАТИЧЕСКИМ рёбрам, с parent-map для отчёта. +const parent = new Map(); +const visited = new Set(); +const queue = []; +for (const entry of ENTRY_FILES) { + if (!visited.has(entry)) { + visited.add(entry); + queue.push(entry); + } +} + +let violation = null; +while (queue.length > 0 && !violation) { + const file = queue.shift(); + for (const forbidden of FORBIDDEN) { + if (file === forbidden) { + violation = file; + break; + } + } + if (violation) break; + + for (const spec of staticImportsOf(file)) { + const resolved = resolveSpecifier(spec, file); + if (!resolved || visited.has(resolved)) continue; + visited.add(resolved); + parent.set(resolved, file); + queue.push(resolved); + } +} + +function chainTo(file) { + const chain = [file]; + let cur = file; + while (parent.has(cur)) { + cur = parent.get(cur); + chain.unshift(cur); + } + return chain.map((f) => path.relative(SRC, f)).join("\n -> "); +} + +if (violation) { + console.error( + "FAIL: /mera-public публичный бандл статически дотягивается до закрытого контура.\n" + + "Цепочка импортов:\n " + + chainTo(violation) + + "\n\nЕсли модуль реально нужен закрытому RBAC-коду — подключай его через " + + "next/dynamic(() => import(...)), как GuardedRoute в RouteGuard.tsx, а не " + + "статическим import.", + ); + process.exit(1); +} + +console.log( + `OK: mera-public isolation — ${ENTRY_FILES.length} файлов проверено, закрытый контур не найден.`, +); diff --git a/tradein-mvp/frontend/src/app/favicon.ico b/tradein-mvp/frontend/src/app/favicon.ico new file mode 100644 index 00000000..b026b8c2 Binary files /dev/null and b/tradein-mvp/frontend/src/app/favicon.ico differ diff --git a/tradein-mvp/frontend/src/app/login/page.tsx b/tradein-mvp/frontend/src/app/login/page.tsx new file mode 100644 index 00000000..3f90c98e --- /dev/null +++ b/tradein-mvp/frontend/src/app/login/page.tsx @@ -0,0 +1,328 @@ +"use client"; + +/** + * #2555 (эпик #2549) — login-форма для новой DB-backed session auth + * (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`). + * + * Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx` — + * `isLoginPage` bypass) — иначе редирект-петля: 401 от /me на /login тоже + * пытался бы увести на /login. + * + * `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ + * через `next/navigation` `useSearchParams()` — тот форсит Suspense boundary + * и ломает `next build` (см. `app/v2/page.tsx: readUrlId` — тот же паттерн, + * уже принятый в этом репо). + */ + +import { useState } from "react"; +import type { CSSProperties, FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch, HTTPError } from "@/lib/api"; +import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget"; + +interface LoginInput { + username: string; + password: string; +} + +async function loginRequest(input: LoginInput): Promise { + await apiFetch<{ ok: boolean }>("/api/v1/auth/login", { + method: "POST", + body: JSON.stringify(input), + }); +} + +function readNextParam(): string | null { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("next"); +} + +/** + * Open-redirect guard: принимаем только внутренний путь, начинающийся + * ровно с одного "/" — не "//host" (protocol-relative URL) и не "/\host" + * (браузеры местами трактуют backslash как forward slash в URL-парсинге). + * + * PR #2562 review finding 1: WHATWG URL-парсер (который `router.push` + * использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД + * парсингом — так `"/\t//evil"` для наивного regex выглядит как безопасный + * путь с одним leading slash (символ в позиции 1 — таб, не "/" и не "\"), + * а после навигации превращается в `"//evil"` (protocol-relative → чужой + * origin). Убираем те же символы ДО валидации, чтобы regex видел ту же + * строку, что увидит парсер. + * + * PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного + * логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит + * `/login`) — dead-end. Фолбэк на "/" в этом случае. + */ +function sanitizeNext(next: string | null): string { + if (!next) return "/"; + const cleaned = next.replace(/[\t\r\n]/g, ""); + if (!/^\/(?!\/|\\)/.test(cleaned)) return "/"; + if ( + cleaned === "/login" || + cleaned.startsWith("/login?") || + cleaned.startsWith("/login#") + ) { + return "/"; + } + return cleaned; +} + +/** + * Единственный 403 логина — «пробный доступ закончился» (пароль ВЕРНЫЙ, + * access_state='trial_expired' в реестре людей). Ветвимся по машиночитаемому + * `detail.code`, а не по тексту: текст сообщения бэк вправе менять, код — нет + * (app/api/v1/auth.py, _ACCESS_EXPIRED_CODE). + * + * Достижимо только при IDENTITY_STORE=auth: в дефолтном режиме состояние + * доступа булево (active/disabled), и trial_expired там не существует. + */ +function accessExpiredCode(body: unknown): string | undefined { + if (typeof body !== "object" || body === null) return undefined; + const detail = (body as { detail?: unknown }).detail; + if (typeof detail !== "object" || detail === null) return undefined; + const code = (detail as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function loginErrorMessage(error: unknown): string { + if (error instanceof HTTPError) { + if (error.status === 401) return "Неверный логин или пароль"; + if (error.status === 429) { + return "Слишком много попыток. Попробуйте через несколько минут"; + } + if (error.status === 403 && accessExpiredCode(error.body) === "access_expired") { + return "Пробный доступ закончился — обратитесь к менеджеру"; + } + } + return "Не удалось войти. Проверьте подключение и попробуйте ещё раз"; +} + +const cardStyle: CSSProperties = { + background: "var(--bg-card)", + border: "1px solid var(--border-card)", + borderRadius: 12, + padding: "32px 28px", + maxWidth: 380, + width: "100%", +}; + +const labelStyle: CSSProperties = { + display: "block", + fontSize: 12, + fontWeight: 500, + color: "var(--fg-secondary)", + marginBottom: 6, +}; + +const inputStyle: CSSProperties = { + width: "100%", + boxSizing: "border-box", + height: 40, + padding: "0 12px", + border: "1px solid var(--border-card)", + borderRadius: 8, + fontSize: 14, + color: "var(--fg-primary)", + background: "var(--bg-card)", + fontFamily: "inherit", +}; + +export default function LoginPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const loginMutation = useMutation({ + mutationFn: loginRequest, + onSuccess: () => { + // #2567: вход под другим юзером без предварительного logout (тот же + // девайс/вкладка) — снимаем ВЕСЬ кэш, не только /me, иначе + // team/employees, история оценок и т.п. предыдущего юзера переживают + // login и могут отрисоваться до первого рефетча. + queryClient.clear(); + router.push(sanitizeNext(readNextParam())); + }, + }); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (loginMutation.isPending) return; + loginMutation.mutate({ username: username.trim(), password }); + } + + return ( +
+ + +
+

+ Вход +

+

+ Войдите, чтобы продолжить работу с Мерой. +

+ +
+ + setUsername(e.target.value)} + disabled={loginMutation.isPending} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loginMutation.isPending} + /> +
+ + {loginMutation.isError ? ( +

+ {loginErrorMessage(loginMutation.error)} +

+ ) : null} + + + +

+ Не получается войти? Напишите нам — кнопка «Поддержка» в правом нижнем + углу. Отвечаем без входа в систему. +

+ + + {/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не + может и сообщить об этом — единственный чат был за логином. */} + +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx new file mode 100644 index 00000000..d1eb1e7f --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx @@ -0,0 +1,232 @@ +"use client"; + +/** + * AddressForm — поле адреса на первом экране. + * + * ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК + * + * Она не считает цену и не притворяется, что считает. Причина техническая и + * жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на + * пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и + * `/api/v1/trade-in/estimate` туда не входят — любой запрос отсюда вернул бы + * 401. Открытие анонимного периметра — отдельный backend-PR, вне границ этой + * задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный + * расчёт ещё не открыт» вместо фейкового спиннера. + * + * Что форма всё-таки делает по-настоящему: + * - проверяет, что адрес введён; + * - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно + * тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил + * «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило + * из шапки `lib/city-registry.ts` — город считается известным только если + * пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте; + * - если названного города нет в покрытии — мягко и честно говорит про + * Свердловскую область, не обещая «оценим любую квартиру в РФ». + * + * Осознанно НЕ переиспользован автокомплит из закрытого контура + * (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через + * `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура + * (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен + * иметь к ним доступа даже теоретически. + * + * Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED` + * в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный + * переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с + * его клавиатурной моделью и sr-live-регионом). + */ + +import { useId, useRef, useState } from "react"; +import type { FormEvent } from "react"; + +import { detectCityInText } from "@/lib/city-registry"; + +import { + COVERED_CITIES, + PRIMARY_CITY, + PUBLIC_ESTIMATE_ENABLED, + REGION_NAME, + SECONDARY_CITIES, +} from "../content"; +import styles from "../landing.module.css"; + +/** Значение
- - ); @@ -236,9 +148,7 @@ export function SystemHealthSection() { flexWrap: "wrap", }} > -
+
-
+
Browser-сервис: - +
- {Object.entries(healthQ.data.browser.browsers).map( - ([name, ok]) => ( -
( +
+ - - {name}: - - -
- ), - )} + {name}: + + +
+ ))}
{/* Per-provider proxy table */} @@ -304,8 +208,6 @@ export function SystemHealthSection() {
- - diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index a6549a3f..ceb10b56 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -15,8 +15,9 @@ import { export interface ScrapeRunFull { run_id: number; source: string; - run_type: string | null; status: string; + /** Джоб этого источника реально опрашивает отмену (бэкенд, scrape_runs.honors_cancel). */ + cancellable: boolean; params: Record | null; counters: Record | null; total_seen: number | null; @@ -32,40 +33,55 @@ interface RunsListResp { rows: ScrapeRunFull[]; } +interface RunSourcesResp { + sources: string[]; +} + // ── Hook ─────────────────────────────────────────────────────────────────── -const RUN_STATUS_ALL = ["", "running", "done", "failed", "cancelled", "zombie", "banned"] as const; +// "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а); +// translateStatus уже знает «пропущено», бейдж падает в нейтральный вариант. +const RUN_STATUS_ALL = [ + "", + "running", + "done", + "failed", + "cancelled", + "zombie", + "banned", + "skipped", +] as const; type RunStatusFilter = (typeof RUN_STATUS_ALL)[number]; -// "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex) -const RUN_SOURCE_FILTERS = ["", "avito", "cian", "yandex"] as const; -type RunSourceFilter = (typeof RUN_SOURCE_FILTERS)[number]; +// #2674: список источников приходит из данных, а не из литерала. Раньше здесь +// стояли три площадки (avito/cian/yandex), а в scrape_runs 53 разных source и НИ +// ОДНОЙ строки с таким точным значением — каждый пункт фильтра давал пустую +// выдачу, а 76% прогонов (вся площадка Домклик в том числе) отфильтровать было +// нечем. Новый источник теперь появляется в списке сам. +function useScrapeRunSources() { + return useQuery({ + queryKey: ["scrape-run-sources"], + // Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при + // каждой смене фильтра, пока летит запрос. + queryFn: () => + apiFetch("/api/v1/admin/scrape/runs/sources"), + staleTime: 60_000, + retry: 1, + }); +} -const SOURCE_FILTER_LABELS: Record = { - "": "Все", - avito: "Avito", - cian: "Cian", - yandex: "Yandex", -}; - -function useScraperRuns( - source: ScraperSource, - status: RunStatusFilter, - sourceFilter: RunSourceFilter, - limit = 20, -) { +// limit=50 (API допускает 200): при выдаче по всем источникам первые 20 строк по +// started_at — на три четверти сердцебиение proxy_healthcheck (1631 прогон из 3245), +// и часовой сбор мог не поместиться на страницу (#2674). +function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 50) { return useQuery({ - queryKey: ["scrape-runs", source, status, sourceFilter, limit], + queryKey: ["scrape-runs", status, sourceFilter, limit], queryFn: () => { const qs = new URLSearchParams({ limit: String(limit) }); - // When a specific sourceFilter is chosen, ignore the tab-level source - // and pass it verbatim as the ?source= param - if (sourceFilter) { - qs.set("source", sourceFilter); - } else { - // fallback: filter by the current tab provider - qs.set("source", source); - } + // Пусто = «Все», и это буквально все источники. Раньше пустой выбор молча + // подставлял source вкладки ("avito"), которого в данных не существует, — + // подпись «Все» показывала ноль прогонов (#2674). + if (sourceFilter) qs.set("source", sourceFilter); if (status) qs.set("status", status); return apiFetch( `/api/v1/admin/scrape/runs?${qs.toString()}`, @@ -175,16 +191,19 @@ interface RunsTableProps { export function RunsTable({ source }: RunsTableProps) { const [statusFilter, setStatusFilter] = useState(""); - const [sourceFilter, setSourceFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); const qc = useQueryClient(); - const runsQ = useScraperRuns(source, statusFilter, sourceFilter); + const runsQ = useScraperRuns(statusFilter, sourceFilter); + const sourcesQ = useScrapeRunSources(); + // mark_cancelled(run_id) source-агностичен — отменяется тот прогон, что выбран + // в таблице, независимо от вкладки, с которой нажали. const cancelMut = useCancelCitySweep(source); function handleCancel(runId: number) { cancelMut.mutate(runId, { onSuccess: () => { void qc.invalidateQueries({ - queryKey: ["scrape-runs", source, statusFilter, sourceFilter], + queryKey: ["scrape-runs", statusFilter, sourceFilter], }); }, }); @@ -194,7 +213,7 @@ export function RunsTable({ source }: RunsTableProps) {

История прогонов

- Последние 20 прогонов. Автообновление каждые 8 сек. + Последние 50 прогонов по всем источникам. Автообновление каждые 8 сек.

{/* Source filter */} @@ -208,7 +227,7 @@ export function RunsTable({ source }: RunsTableProps) { + {sourcesQ.isError && ( + + список источников не загрузился — фильтр показывает только «Все» + + )} {/* Status filter */} @@ -290,7 +315,10 @@ export function RunsTable({ source }: RunsTableProps) {
- + {/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один + ('city_sweep' по DEFAULT) и подписывал так даже + proxy_healthcheck. Что бежало — говорит source. */} + @@ -312,7 +340,7 @@ export function RunsTable({ source }: RunsTableProps) { color: "var(--fg-secondary, #5b6066)", }} > - {r.run_type ?? "sweep"} + {r.source}
- - {provider.current_ip ?? "—"} - - - - {provider.rotate_supported ? "rotate" : "статический"} - - - {provider.rotate_supported ? ( - - ) : ( - - — - - )} - {rotateMut.isSuccess && rotateMut.data.ok && ( - - {rotateMut.data.new_ip ?? "обновлён"} - - )} - {rotateMut.isSuccess && !rotateMut.data.ok && ( - - {rotateMut.data.reason ?? "ошибка"} - - )} - {rotateMut.isError && ( - - {rotateMut.error.message} - - )} + {provider.current_ip ?? "—"}
Провайдер Прокси (host:port) Текущий IPРежимДействие
#ТипИсточник Статус Старт Финиш @@ -354,7 +382,9 @@ export function RunsTable({ source }: RunsTableProps) { )} - {r.status === "running" && ( + {/* cancellable — от бэкенда (#2674): у остальных источников + отмена поставила бы статус 'cancelled' работающей задаче. */} + {r.status === "running" && r.cancellable && ( + + + + ); + } + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Новый сотрудник

+

+ Пароль задаётся вручную — передайте его сотруднику лично, рассылки нет. +

+ + {createMutation.isError ? ( +

+ {teamErrorMessage(createMutation.error)} +

+ ) : null} + +
+ + setUsername(e.target.value)} + onBlur={() => setUsernameTouched(true)} + disabled={createMutation.isPending} + aria-invalid={usernameInvalid} + /> +

{USERNAME_HINT}

+ {usernameInvalid ? ( +

+ Логин не соответствует формату +

+ ) : null} +
+ +
+ + setPassword(e.target.value)} + disabled={createMutation.isPending} + /> + +
+ +
+ + setDisplayName(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setOrgName(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setEmail(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setMonthlyLimit(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx b/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx new file mode 100644 index 00000000..0d55066e --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx @@ -0,0 +1,94 @@ +"use client"; + +/** + * Drawer с историей оценок сотрудника (#2556, эпик #2549) — + * `GET /api/v1/team/employees/{id}/history`. + */ + +import { teamErrorMessage, useEmployeeHistory } from "@/lib/team-api"; +import type { Employee } from "@/lib/team-api"; + +function formatDateTime(iso: string): string { + try { + return new Date(iso).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +function formatPrice(value: number | null): string { + if (value === null) return "—"; + return `${value.toLocaleString("ru-RU")} ₽`; +} + +interface EmployeeHistoryDrawerProps { + employee: Employee; + onClose: () => void; +} + +export function EmployeeHistoryDrawer({ employee, onClose }: EmployeeHistoryDrawerProps) { + const historyQ = useEmployeeHistory(employee.id, 50, 0); + + return ( + <> +
+ + + ); +} diff --git a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx new file mode 100644 index 00000000..1b454fb3 --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx @@ -0,0 +1,222 @@ +"use client"; + +/** + * Таблица сотрудников для team-дашборда менеджера (#2556, эпик #2549). + * + * Блокировка/разблокировка — прямое действие в строке (один PATCH-запрос + * с `is_active`), с `window.confirm` перед деструктивным изменением (спек + * DoD: «действия деструктивные — с подтверждением»). Квота + сброс пароля + * вынесены в отдельный модал (`QuotaEditor`) — они бьют в тот же PATCH + * эндпоинт одним запросом, см. `app/api/v1/team.py::update_employee`. + */ + +import { Fragment, useState } from "react"; + +import type { Employee } from "@/lib/team-api"; +import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api"; + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return iso; + } +} + +/** + * Винительный падеж для confirm-текста: список admin'а содержит и менеджеров + * (см. `Employee.role`), а «Заблокировать сотрудника «praktika»?» про владельца + * организации — прямая дезинформация в самом опасном диалоге дашборда. + */ +function roleAccusative(role: Employee["role"]): string { + return role === "manager" ? "менеджера" : "сотрудника"; +} + +function QuotaCell({ quota }: { quota: Employee["quota"] }) { + if (quota.unlimited) { + return без лимита; + } + const low = quota.remaining <= 0; + return ( + + {quota.used}/{quota.limit} + + ); +} + +interface EmployeeTableProps { + employees: Employee[]; + onEditQuota: (employee: Employee) => void; + onViewHistory: (employee: Employee) => void; + limit: number; + offset: number; + onOffsetChange: (offset: number) => void; + hasNextPage: boolean; +} + +export function EmployeeTable({ + employees, + onEditQuota, + onViewHistory, + limit, + offset, + onOffsetChange, + hasNextPage, +}: EmployeeTableProps) { + const updateMutation = useUpdateEmployee(); + const [pendingId, setPendingId] = useState(null); + const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null); + + function handleToggleActive(employee: Employee) { + const nextActive = !employee.is_active; + const who = roleAccusative(employee.role); + const confirmed = window.confirm( + nextActive + ? `Разблокировать ${who} «${employee.username}»?` + : `Заблокировать ${who} «${employee.username}»? Все его текущие сессии будут завершены.`, + ); + if (!confirmed) return; + + setRowError(null); + setPendingId(employee.id); + updateMutation.mutate( + { employeeId: employee.id, patch: { is_active: nextActive } }, + { + onSettled: () => setPendingId(null), + onError: (error) => setRowError({ id: employee.id, message: teamErrorMessage(error) }), + }, + ); + } + + const isEmpty = employees.length === 0; + // Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать). + // Пустая страница при offset>0 = перешли за границу списка (напр. ровно + // 50/100/150 сотрудников и клик «Далее» после последней полной страницы) + // — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера. + const isEmptyPastEnd = isEmpty && offset > 0; + + return ( + <> + {isEmpty ? ( +

+ {isEmptyPastEnd + ? "На этой странице пусто — вы перешли за конец списка." + : "Сотрудников пока нет. Добавьте первого через «+ Добавить сотрудника»."} +

+ ) : ( + + + + + + + + + + + + + {employees.map((employee) => ( + + + + + + + + + + {rowError?.id === employee.id ? ( + + + + ) : null} + + ))} + +
ЛогинИмяСтатусКвотаСозданДействия
+ {employee.username} + {employee.role === "manager" ? ( + + менеджер + + ) : null} + + {employee.display_name ?? "—"} + {employee.org_name ? ` · ${employee.org_name}` : ""} + + + {employee.is_active ? "Активен" : "Заблокирован"} + + + + {formatDate(employee.created_at)} +
+ + + +
+
+

+ {rowError.message} +

+
+ )} + +
+ + {!isEmpty ? ( + + {offset + 1}–{offset + employees.length} + + ) : null} + +
+ + ); +} diff --git a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx new file mode 100644 index 00000000..94b2db38 --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx @@ -0,0 +1,147 @@ +"use client"; + +/** + * Модалка изменения сотрудника (#2556, эпик #2549) — месячная квота + (опц.) + * сброс пароля. Один PATCH-запрос `/api/v1/team/employees/{id}` — backend + * принимает `monthly_limit` и `new_password` в одном теле (см. + * `app/api/v1/team.py::update_employee`), поэтому оба поля живут в одной + * форме вместо двух раздельных round-trip'ов. + * + * Явная установка `monthly_limit` ВСЕГДА сбрасывает `unlimited=false` на + * бэкенде (см. `_upsert_quota_override`) — предупреждаем об этом в тексте, + * если у сотрудника сейчас безлимит. + */ + +import { useState } from "react"; +import type { FormEvent } from "react"; + +import type { Employee, UpdateEmployeeInput } from "@/lib/team-api"; +import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api"; + +interface QuotaEditorProps { + employee: Employee; + onClose: () => void; +} + +export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { + const [monthlyLimit, setMonthlyLimit] = useState( + employee.quota.unlimited ? "" : String(employee.quota.limit), + ); + const [newPassword, setNewPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + // Список admin'а смешанный (сотрудники + менеджеры организаций) — «Изменить + // сотрудника» над карточкой владельца организации вводит в заблуждение. + const who = employee.role === "manager" ? "менеджера" : "сотрудника"; + + const updateMutation = useUpdateEmployee(); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (updateMutation.isPending) return; + + const patch: UpdateEmployeeInput = {}; + + const trimmedLimit = monthlyLimit.trim(); + if (trimmedLimit !== "") { + const parsed = Number(trimmedLimit); + if (Number.isFinite(parsed) && parsed >= 1) { + patch.monthly_limit = Math.trunc(parsed); + } + } + + if (newPassword.length > 0) { + patch.new_password = newPassword; + } + + if (Object.keys(patch).length === 0) { + onClose(); + return; + } + + updateMutation.mutate( + { employeeId: employee.id, patch }, + { onSuccess: onClose }, + ); + } + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Изменить {who}

+

{employee.username}

+ + {updateMutation.isError ? ( +

+ {teamErrorMessage(updateMutation.error)} +

+ ) : null} + +
+ + setMonthlyLimit(e.target.value)} + disabled={updateMutation.isPending} + /> +

+ Сейчас использовано {employee.quota.used} + {employee.quota.unlimited + ? " · без лимита" + : ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`} + {employee.quota.unlimited + ? ". Заполнение поля снимет безлимитный статус." + : ""} +

+
+ +
+ + setNewPassword(e.target.value)} + disabled={updateMutation.isPending} + /> + +

+ Смена пароля завершает все текущие сессии {who}. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx b/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx index cd0d962c..62093315 100644 --- a/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx @@ -2,60 +2,27 @@ /** * MapCard — карта аналитики: целевая квартира + аналоги (asking) + ДКП-сделки. - * Reuses the same CDN-loaded Leaflet + OSM approach as MapPicker.tsx (no npm dep). + * Leaflet + OSM грузятся через общий @/lib/leaflet (no npm dep). * Пины с null lat/lon пропускаются; карточка не рендерится без гео-точек. */ /* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */ import { useEffect, useMemo, useRef, useState } from "react"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; -const LEAFLET_VER = "1.9.4"; -const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; -const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; -const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; -const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; - // Цвета пинов: asking (синий --viz-1) / ДКП (бирюза --viz-3) / target (янтарь --viz-4). // Leaflet принимает строку-цвет напрямую (не CSS vars) — используем hex-значения токенов. const COLOR_ANALOG = "#1d4ed8"; // --viz-1 const COLOR_DEAL = "#14b8a6"; // --viz-3 const COLOR_TARGET = "#f59e0b"; // --viz-4 -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapPicker) */ -function loadLeaflet(): Promise { - return new Promise((resolve, reject) => { - const w = window as any; - if (w.L) { - resolve(w.L); - return; - } - if (!document.querySelector(`link[data-leaflet]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = LEAFLET_CSS; - link.integrity = LEAFLET_CSS_SRI; - link.crossOrigin = "anonymous"; - link.setAttribute("data-leaflet", "1"); - document.head.appendChild(link); - } - const existing = document.querySelector(`script[data-leaflet]`); - if (existing) { - existing.addEventListener("load", () => resolve(w.L)); - existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); - return; - } - const script = document.createElement("script"); - script.src = LEAFLET_JS; - script.integrity = LEAFLET_JS_SRI; - script.crossOrigin = "anonymous"; - script.setAttribute("data-leaflet", "1"); - script.onload = () => resolve(w.L); - script.onerror = () => reject(new Error("leaflet load failed")); - document.body.appendChild(script); - }); -} - interface Props { estimate: AggregatedEstimate; } @@ -123,10 +90,10 @@ export function MapCard({ estimate }: Props) { center = [firstPin.lat as number, firstPin.lon as number]; } if (!center) return; // guarded by totalPins>0, but satisfies TS - map = L.map(mapRef.current).setView(center, 14); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + map = createMap(L, mapRef.current).setView(center, 14); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx b/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx index e3296798..462b21d4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx @@ -6,12 +6,14 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { API_BASE_URL } from "@/lib/api"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; -const LEAFLET_VER = "1.9.4"; -const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; -const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; -const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; -const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; const EKB_CENTER: [number, number] = [56.8389, 60.6057]; /** Precision значения от reverse endpoint, при которых имеет смысл двигать @@ -51,40 +53,6 @@ interface ReverseResponse { provider: string; } -/** Подгружает Leaflet с CDN один раз, резолвит window.L. */ -function loadLeaflet(): Promise { - return new Promise((resolve, reject) => { - const w = window as any; - if (w.L) { - resolve(w.L); - return; - } - if (!document.querySelector(`link[data-leaflet]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = LEAFLET_CSS; - link.integrity = LEAFLET_CSS_SRI; - link.crossOrigin = "anonymous"; - link.setAttribute("data-leaflet", "1"); - document.head.appendChild(link); - } - const existing = document.querySelector(`script[data-leaflet]`); - if (existing) { - existing.addEventListener("load", () => resolve(w.L)); - existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); - return; - } - const script = document.createElement("script"); - script.src = LEAFLET_JS; - script.integrity = LEAFLET_JS_SRI; - script.crossOrigin = "anonymous"; - script.setAttribute("data-leaflet", "1"); - script.onload = () => resolve(w.L); - script.onerror = () => reject(new Error("leaflet load failed")); - document.body.appendChild(script); - }); -} - interface Props { onPick: (address: string, coords?: { lat: number; lon: number }) => void; onClose: () => void; @@ -115,10 +83,10 @@ export function MapPicker({ onPick, onClose }: Props) { loadLeaflet() .then((L) => { if (cancelled || !mapRef.current) return; - map = L.map(mapRef.current).setView(EKB_CENTER, 12); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + map = createMap(L, mapRef.current).setView(EKB_CENTER, 12); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx b/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx index 6a64dd44..adaa9bf1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx @@ -3,8 +3,8 @@ /** * SaleShareMap — карта домов вторичного рынка, окрашенных по доле квартир в * продаже (sale_share_pct): низкая → зелёный, высокая → красный. Размер маркера - * тоже растёт с долей. Reuses the same CDN-loaded Leaflet + OSM approach as - * MapCard.tsx / MapPicker.tsx (no npm dep). Дома без координат пропускаем. + * тоже растёт с долей. Leaflet + OSM грузятся через общий @/lib/leaflet + * (no npm dep). Дома без координат пропускаем. * * Грузится через next/dynamic({ ssr:false }) из page.tsx — window.L доступен * только в браузере. @@ -12,50 +12,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */ import { useEffect, useMemo, useRef, useState } from "react"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import type { BuildingSaleShare } from "@/types/sale-share"; import { fmtPct, heatColor, markerRadius } from "./saleShareUtils"; -const LEAFLET_VER = "1.9.4"; -const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; -const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; -const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; -const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; const EKB_CENTER: [number, number] = [56.8389, 60.6057]; -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard) */ -function loadLeaflet(): Promise { - return new Promise((resolve, reject) => { - const w = window as any; - if (w.L) { - resolve(w.L); - return; - } - if (!document.querySelector(`link[data-leaflet]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = LEAFLET_CSS; - link.integrity = LEAFLET_CSS_SRI; - link.crossOrigin = "anonymous"; - link.setAttribute("data-leaflet", "1"); - document.head.appendChild(link); - } - const existing = document.querySelector(`script[data-leaflet]`); - if (existing) { - existing.addEventListener("load", () => resolve(w.L)); - existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); - return; - } - const script = document.createElement("script"); - script.src = LEAFLET_JS; - script.integrity = LEAFLET_JS_SRI; - script.crossOrigin = "anonymous"; - script.setAttribute("data-leaflet", "1"); - script.onload = () => resolve(w.L); - script.onerror = () => reject(new Error("leaflet load failed")); - document.body.appendChild(script); - }); -} - /** Безопасное экранирование для вставки в HTML popup. */ function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => @@ -126,10 +94,10 @@ export function SaleShareMap({ .then((L) => { if (cancelled || !mapRef.current) return; LRef.current = L; - const map = L.map(mapRef.current).setView(EKB_CENTER, 11); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + const map = createMap(L, mapRef.current).setView(EKB_CENTER, 11); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); layerRef.current = L.layerGroup().addTo(map); mapObj.current = map; diff --git a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx index 69091f28..7a866783 100644 --- a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx @@ -114,6 +114,11 @@ export function StreetDealsCard({ estimate }: Props) { )}
)} + {/* #2666: медиана не прошла гейт правдоподобия — показываем причину, + а не пустое место (тот же паттерн, что confidence_explanation). */} + {data.median_discount_explanation && ( +
{data.median_discount_explanation}
+ )} {data.data_quality === "street_only" && (
Данные по улице, не по конкретному дому: привязать сделки ДКП к diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 094dda63..7769adc9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -7,6 +7,7 @@ import { API_BASE_URL, HTTPError } from "@/lib/api"; import { isPathAllowed } from "@/lib/isPathAllowed"; import { safeUrl } from "@/lib/safeUrl"; import { useBrand } from "@/lib/useBrand"; +import type { Role } from "@/lib/useMe"; import { useMe } from "@/lib/useMe"; /** Telegram-канал обратной связи для пилота. Build-time env — пусто до тех @@ -84,7 +85,8 @@ export type ActiveTab = | "cian" | "yandex" | "audit" - | "analytics"; + | "analytics" + | "team"; interface TopbarProps { active: ActiveTab; @@ -105,15 +107,43 @@ interface TopbarProps { * путь, чтобы pilot их не видел в навигации. Direct URL access на * `/trade-in/scrapers/avito` НЕ блокируется (RouteGuard следует yaml). Если * нужна полная блокировка — добавить `/trade-in/scrapers/**` в pilot.deny. + * + * Исключение из этого caveat — `sale-share` и `cache` (2026-07-31): для них + * заведён ЯВНЫЙ deny (`/trade-in/sale-share/**`, `/trade-in/cache/**` + их API) + * в `DB_ROLE_PATHS` (employee/manager) и в `pilot.deny` (auth/roles.yaml). + * Т.е. это НЕ scopePath-трюк, как у скрапперов: гейт реальный, а не только + * косметический. + * + * Но точность важнее красивой формулировки — где именно он стоит: + * - пункт меню исчезает (фильтр ниже, deny из `/me`); + * - страница по прямому URL отдаёт HTTP **200** с HTML (Next.js рендерит + * маршрут всегда) — её закрывает КЛИЕНТСКИЙ `RouteGuard` (app/layout.tsx), + * рисуя NoAccessScreen вместо контента; + * - единственный СЕРВЕРНЫЙ рубеж — API: `/api/v1/buildings/**` и + * `/api/v1/trade-in/cache-stats/**` дают 403 из `rbac_guard`. + * Данные без API недостижимы, поэтому 200 на HTML безвреден — но не читай это + * как «страница блокируется на сервере»: следующий, кто добавит сюда раздел с + * SSR-данными, обязан закрывать именно его API, а не только этот список. */ const NAV_ITEMS: Array<{ key: ActiveTab; href: string; scopePath: string; label: string; + // Доп. gate ПОВЕРХ isPathAllowed — нужен для "team": DB-роль `manager` + // получает `/api/v1/team/**` в allowed_paths (см. DB_ROLE_PATHS, + // tradein-mvp/backend/app/services/auth_session.py), но legacy-роль + // `analyst` (auth/roles.yaml, paths: "/**", БЕЗ явного deny на /api/v1/team) + // тоже прошла бы обычный isPathAllowed-фильтр — analyst не должен видеть + // пункт «Команда» (#2556 scope: только admin/manager). + roleGate?: (role: Role) => boolean; }> = [ { key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" }, - // Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**). + // Доля квартир дома в продаже — ВНУТРЕННИЙ инструмент (аналитика рынка). + // Скрыт для employee/manager/pilot явным deny `/trade-in/sale-share/**` + // (DB_ROLE_PATHS + auth/roles.yaml), а не scopePath-трюком как у скрапперов: + // scopePath остаётся честным путём страницы, фильтр ниже — прежний + // isPathAllowed, просто deny побеждает allow `/trade-in/**`. { key: "sale-share", href: "/sale-share", @@ -121,6 +151,8 @@ const NAV_ITEMS: Array<{ label: "Доля в продаже", }, { key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" }, + // Кэш — внутренний инструмент (состояние кэшей/скраперов). Скрыт тем же + // способом, что и sale-share выше: явный deny `/trade-in/cache/**`. { key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" }, // Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел. { @@ -162,6 +194,16 @@ const NAV_ITEMS: Array<{ scopePath: "/trade-in/api/v1/admin/analytics", label: "Активность", }, + // Team-дашборд менеджера (#2556, эпик #2549) — CRUD сотрудников/квоты/история. + // scopePath БЕЗ /trade-in-префикса — DB_ROLE_PATHS отдаёт голый /api/v1/team/** + // (не /trade-in/api/v1/team/**), см. комментарий у roleGate выше. + { + key: "team", + href: "/team", + scopePath: "/api/v1/team", + label: "Команда", + roleGate: (role) => role === "admin" || role === "manager", + }, ]; export function Topbar({ active }: TopbarProps) { @@ -176,8 +218,10 @@ export function Topbar({ active }: TopbarProps) { const isDev401 = error instanceof HTTPError && error.status === 401; const items = data && !isDev401 - ? NAV_ITEMS.filter((item) => - isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath), + ? NAV_ITEMS.filter( + (item) => + isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) && + (item.roleGate ? item.roleGate(data.role) : true), ) : NAV_ITEMS; diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 6a7bcbfe..a7c6e9e0 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -2820,3 +2820,335 @@ html, body { overflow-x: clip; } grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; } + +/* ── Team dashboard (#2556, эпик #2549) ── */ + +.team-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.team-status-badge { + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + +.team-status-badge--active { + background: var(--success-soft, #dcfce7); + color: var(--success, #0a7a3a); +} + +.team-status-badge--blocked { + background: var(--danger-soft, #fee2e2); + color: var(--danger, #b3261e); +} + +/* Пометка «менеджер» рядом с логином в списке admin'а: список смешанный + (сотрудники + менеджеры организаций), без метки строки неразличимы. */ +.team-role-badge { + margin-left: 6px; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + background: var(--accent-soft, #e0edff); + color: var(--accent, #0d6fd6); +} + +.team-quota-unlimited { + color: var(--fg-secondary, #5b6066); + font-size: 11px; +} + +.team-quota-low { + color: var(--danger, #b3261e); + font-weight: 600; +} + +.team-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.team-action-btn { + padding: 4px 10px; + background: var(--bg-card-alt, #fafbfc); + color: var(--fg-primary, #111111); + border: 1px solid var(--border-strong, #d1d5db); + border-radius: 4px; + font-size: 11px; + cursor: pointer; +} + +.team-action-btn:hover:not(:disabled) { + background: var(--accent-soft, #dbeafe); + border-color: var(--accent, #1d4ed8); +} + +.team-action-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.team-action-btn--danger { + color: var(--danger, #b3261e); + border-color: var(--danger, #b3261e); +} + +.team-action-btn--danger:hover:not(:disabled) { + background: var(--danger-soft, #fee2e2); +} + +.team-pager { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + font-size: 13px; + color: var(--fg-secondary, #5b6066); +} + +.team-pager button { + padding: 6px 12px; + background: var(--bg-card, #ffffff); + border: 1px solid var(--border-strong, #d1d5db); + border-radius: 6px; + font-size: 13px; + cursor: pointer; +} + +.team-pager button:hover:not(:disabled) { + background: var(--accent-soft, #dbeafe); + border-color: var(--accent, #1d4ed8); +} + +.team-pager button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +/* Modal (CreateEmployeeForm / QuotaEditor) — box-shadow допустим для + modals/popovers per .claude/rules/ui-tokens.md. */ +.team-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 48px 16px; + z-index: 100; + overflow-y: auto; +} + +.team-modal { + background: var(--bg-card, #ffffff); + border: 1px solid var(--border-card, #e6e8ec); + border-radius: 12px; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2); + padding: 24px; + max-width: 440px; + width: 100%; +} + +.team-modal h2 { + font-size: 18px; + font-weight: 600; + margin: 0 0 4px; + color: var(--fg-primary, #111111); +} + +.team-modal p.team-modal-subtitle { + font-size: 13px; + color: var(--fg-secondary, #5b6066); + margin: 0 0 20px; +} + +.team-field { + margin-bottom: 14px; +} + +.team-field label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--fg-secondary, #5b6066); + margin-bottom: 6px; +} + +.team-field input { + width: 100%; + box-sizing: border-box; + height: 38px; + padding: 0 12px; + border: 1px solid var(--border-card, #e6e8ec); + border-radius: 8px; + font-size: 14px; + color: var(--fg-primary, #111111); + background: var(--bg-card, #ffffff); + font-family: inherit; +} + +.team-field input:focus-visible { + outline: none; + border-color: var(--accent, #1d4ed8); + box-shadow: 0 0 0 2px var(--accent-soft, #dbeafe); +} + +.team-field-hint { + margin: 6px 0 0; + font-size: 11px; + color: var(--fg-tertiary, #73767e); +} + +.team-password-row { + position: relative; +} + +.team-password-toggle { + position: absolute; + right: 8px; + top: 30px; + background: none; + border: none; + color: var(--fg-secondary, #5b6066); + font-size: 11px; + cursor: pointer; + padding: 4px 6px; +} + +.team-form-error { + margin: 0 0 16px; + padding: 8px 12px; + border-radius: 8px; + background: var(--danger-soft, #fee2e2); + color: var(--danger, #b3261e); + font-size: 13px; + line-height: 1.4; +} + +.team-form-success { + margin: 0 0 16px; + padding: 8px 12px; + border-radius: 8px; + background: var(--success-soft, #dcfce7); + color: var(--success, #0a7a3a); + font-size: 13px; + line-height: 1.4; +} + +.team-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.team-btn-primary { + padding: 9px 16px; + background: var(--accent, #1d4ed8); + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.team-btn-primary:hover:not(:disabled) { + background: var(--accent-hover, #1e40af); +} + +.team-btn-primary:disabled { + background: var(--border-strong, #d1d5db); + cursor: not-allowed; +} + +.team-btn-secondary { + padding: 9px 16px; + background: var(--bg-card, #ffffff); + color: var(--fg-primary, #111111); + border: 1px solid var(--border-strong, #d1d5db); + border-radius: 8px; + font-size: 14px; + cursor: pointer; +} + +.team-btn-secondary:hover:not(:disabled) { + background: var(--bg-card-alt, #fafbfc); +} + +/* Drawer (EmployeeHistoryDrawer) */ +.team-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.35); + z-index: 100; +} + +.team-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(480px, 100vw); + background: var(--bg-card, #ffffff); + border-left: 1px solid var(--border-card, #e6e8ec); + box-shadow: -16px 0 40px rgba(15, 23, 42, 0.15); + z-index: 101; + overflow-y: auto; + padding: 24px; +} + +.team-drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.team-drawer-header h2 { + font-size: 18px; + font-weight: 600; + margin: 0 0 4px; + color: var(--fg-primary, #111111); +} + +.team-drawer-close { + background: none; + border: none; + font-size: 20px; + line-height: 1; + color: var(--fg-secondary, #5b6066); + cursor: pointer; + padding: 4px 6px; +} + +.team-history-row { + padding: 10px 0; + border-bottom: 1px solid var(--border-soft, #eef0f3); +} + +.team-history-row:last-child { + border-bottom: none; +} + +.team-history-address { + font-size: 13px; + color: var(--fg-primary, #111111); + margin: 0 0 2px; +} + +.team-history-meta { + font-size: 11px; + color: var(--fg-secondary, #5b6066); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index ad42fcc6..c28f0f6c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -3,6 +3,13 @@ import { useEffect, useRef, useState, type CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import { safeUrl } from "@/lib/safeUrl"; import { tokens } from "./tokens"; @@ -36,50 +43,8 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null { // Replaces the old static building.png stock photo — user-reported bug: that // single asset was shown for EVERY estimate regardless of the real address, // misleading users into thinking they were looking at their own building. -// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported -// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this -// file stays a self-contained port with no shared runtime module and no npm -// Leaflet dep, same rationale as SourcesMap. -/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */ -const LEAFLET_VER = "1.9.4"; -const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; -const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; -const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; -const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; - -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */ -function loadLeaflet(): Promise { - return new Promise((resolve, reject) => { - const w = window as any; - if (w.L) { - resolve(w.L); - return; - } - if (!document.querySelector(`link[data-leaflet]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = LEAFLET_CSS; - link.integrity = LEAFLET_CSS_SRI; - link.crossOrigin = "anonymous"; - link.setAttribute("data-leaflet", "1"); - document.head.appendChild(link); - } - const existing = document.querySelector(`script[data-leaflet]`); - if (existing) { - existing.addEventListener("load", () => resolve(w.L)); - existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); - return; - } - const script = document.createElement("script"); - script.src = LEAFLET_JS; - script.integrity = LEAFLET_JS_SRI; - script.crossOrigin = "anonymous"; - script.setAttribute("data-leaflet", "1"); - script.onload = () => resolve(w.L); - script.onerror = () => reject(new Error("leaflet load failed")); - document.body.appendChild(script); - }); -} +// Leaflet + OSM грузятся через общий @/lib/leaflet (CDN + SRI, no npm dep). +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. @/lib/leaflet) */ // Overview zoom: close enough to recognise the actual building on a 560×152 // box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin @@ -117,7 +82,7 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) { loadLeaflet() .then((L) => { if (cancelled || !mapRef.current) return; - map = L.map(mapRef.current, { + map = createMap(L, mapRef.current, { scrollWheelZoom: false, // embedded in the page — must not steal page scroll dragging: false, // locator badge, not an explorable map touchZoom: false, @@ -125,9 +90,9 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) { zoomControl: false, // no room for +/- controls at this size keyboard: false, }).setView([lat, lon], HERO_MAP_ZOOM); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); L.circleMarker([lat, lon], { radius: 8, diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx index e9c9872b..bcdffa01 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -342,6 +342,27 @@ export function ObjectSummary({ {data.summary.quality.cv}
+ {/* M1 audit #2583 — backend's own honest explanation for the + confidence tier above (e.g. "расширили радиус до 2 км из-за + нехватки данных"). Was computed by the backend but never shown + anywhere in v2 (v1 showed it). Rendered visibly, not just as a + hover tooltip (mirrored in ResultPanel's ДОСТОВЕРНОСТЬ) — this + rail has room, and this is the more likely place a user actually + reads "почему такая достоверность". undefined → nothing renders. */} + {data.summary.quality.confidenceExplanation && ( +
+ {data.summary.quality.confidenceExplanation} +
+ )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 251e66e3..91b8dc93 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -13,10 +13,8 @@ // // Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius // rings) with no real coordinates behind it. It is now a real Leaflet + OSM -// basemap, ported from the same CDN-loader pattern as ./SourcesMap.tsx / -// ../MapPicker.tsx (copied rather than shared — same self-contained-port -// convention as SourcesMap.tsx, no npm Leaflet dep). -/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */ +// basemap, загружаемый общим @/lib/leaflet (CDN + SRI, no npm Leaflet dep). +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. @/lib/leaflet) */ import { useEffect, @@ -38,6 +36,19 @@ import { type MapMarker, } from "./mappers"; import { useGeocodeSuggest } from "@/lib/trade-in-api"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; +import { + CITY_LABELS, + DEFAULT_CITY, + UNCONFIRMED_CITY_LABEL, + detectCityInText, +} from "@/lib/city-registry"; import type { GeocodeSuggestion, HouseType, @@ -45,7 +56,7 @@ import type { TradeInEstimateInput, } from "@/types/trade-in"; -type DdKey = "rooms" | "houseType" | "repair" | "radius" | null; +type DdKey = "rooms" | "houseType" | "repair" | "radius" | "city" | null; // Russian plural picker (one / few / many) for SR announcements. function pluralRu(n: number, one: string, few: string, many: string): string { @@ -151,53 +162,11 @@ function comboKeyDown( } // ── 01 map — Leaflet + OSM (real coordinates) ─────────────────────────────── -// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx -// (duplicated on purpose — each v2 file is a self-contained port, no shared -// runtime module, no npm Leaflet dep). -const LEAFLET_VER = "1.9.4"; -const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; -const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; -const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; -const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; -const MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; -const MAP_ATTRIBUTION = "© OpenStreetMap"; +// Загрузчик/версия/SRI/тайлы/атрибуция — общие, из @/lib/leaflet. Зумы ниже +// специфичны для этой карточки (степперы +/− в UI ограничены этим диапазоном). const DEFAULT_MAP_ZOOM = 16; const MIN_MAP_ZOOM = 11; -const MAX_MAP_ZOOM = 19; - -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */ -function loadLeaflet(): Promise { - return new Promise((resolve, reject) => { - const w = window as any; - if (w.L) { - resolve(w.L); - return; - } - if (!document.querySelector(`link[data-leaflet]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = LEAFLET_CSS; - link.integrity = LEAFLET_CSS_SRI; - link.crossOrigin = "anonymous"; - link.setAttribute("data-leaflet", "1"); - document.head.appendChild(link); - } - const existing = document.querySelector(`script[data-leaflet]`); - if (existing) { - existing.addEventListener("load", () => resolve(w.L)); - existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); - return; - } - const script = document.createElement("script"); - script.src = LEAFLET_JS; - script.integrity = LEAFLET_JS_SRI; - script.crossOrigin = "anonymous"; - script.setAttribute("data-leaflet", "1"); - script.onload = () => resolve(w.L); - script.onerror = () => reject(new Error("leaflet load failed")); - document.body.appendChild(script); - }); -} +const MAX_MAP_ZOOM = OSM_MAX_ZOOM; /** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес — ввод * пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */ @@ -523,6 +492,39 @@ const errorText: CSSProperties = { color: tokens.danger, }; +// #2576 — honest, calm (не danger) heads-up когда бэкенд вернул +// target_city_ambiguous: true: город определил геокодер, не пользователь. +// Тот же тон, что и другие честные caption'ы в этой панели (не паника, не +// ошибка формы — просто прозрачность + предложение уточнить). +const cityAmbiguousText: CSSProperties = { + marginTop: 4, + fontSize: 10.5, + letterSpacing: 0.3, + lineHeight: 1.4, + color: tokens.warn, +}; + +// #2632 — same honest/calm tone as cityAmbiguousText above: the backend +// silently widened the search radius (1 км дефолт → 2 км fallback) because +// there weren't enough analogs nearby. Disclosure, not an error — the circle +// on the map already moved to match; this caption says WHY in words, right +// next to it (backend's own confidence_explanation carries the same fact, +// but lives in a separate panel that may be scrolled out of view). +const radiusExpandedText: CSSProperties = { + marginTop: 8, + fontSize: 10.5, + letterSpacing: 0.3, + lineHeight: 1.4, + color: tokens.warn, +}; + +// 1000/2000 м → "1 км"/"2 км" (matches how people actually talk about the +// РАДИУС options); anything not a whole km (e.g. a 300/500 m override) stays +// in metres. +function formatRadiusRu(m: number): string { + return m % 1000 === 0 ? `${m / 1000} км` : `${m} м`; +} + // РАДИУС dropdown panel — mirrors the
HUD panel (surface.w98 + soft blue // shadow), sized to the narrow radius trigger and dropped just beneath it. const radiusPanel: CSSProperties = { @@ -551,6 +553,12 @@ interface ParamsPanelProps { error?: string | null; /** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */ initialValues?: Partial; + /** + * #2576: true когда последняя оценка вернулась с `target_city_ambiguous` — + * геокодер сам угадал город (пользователь его не назвал явно). Рисует + * честную некритичную подсказку у поля адреса, не блокирует форму. + */ + cityAmbiguous?: boolean; /** Analog price pins, projected from the real estimate via mapMarkers() onto * the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see * mapMarkers() comment in ./mappers.ts). Kept in the prop contract for @@ -559,6 +567,17 @@ interface ParamsPanelProps { * correspond to real lat/lon at the map's actual zoom — projecting them * would be a new, subtler version of the honesty bug this map replaces. */ markers?: MapMarker[]; + /** + * #2632: фактический радиус (м), по которому backend реально отобрал + * listings-аналоги для ПОСЛЕДНЕЙ оценки (AggregatedEstimate.search_radius_m) + * — может отличаться от выбора пользователя в дропдауне РАДИУС, если сервер + * молча расширил поиск (1 км → 2 км) при нехватке аналогов. Пока пользователь + * не тронул дропдаун в текущей сессии панели — карта и disclosure-подсказка + * ориентируются на это значение, а не на дефолтный превью-радиус. null/undefined + * (нет оценки ещё / старая оценка без поля) → полный fallback на выбор + * пользователя, как раньше. + */ + searchRadiusM?: number | null; } // rooms number -> dropdown label. The design has no «Студия» option, so studio @@ -578,6 +597,31 @@ function initRepairLabel(rs: RepairState | undefined): string { return rs ? REPAIR_RU[rs] : "Не указано"; } +// Город формы (#2576) — ДВЕ раздельных величины, не одна: +// - `city` (initCityLabel) — best-guess ДЛЯ ОТОБРАЖЕНИЯ/подстановки после +// подтверждения. Restore-by-id: detect из сохранённого адреса (иначе сброс +// на Екатеринбург молча увёл бы восстановленный отчёт другого города назад +// на ЕКБ-подсказки); новая форма без initialValues → DEFAULT_CITY. +// - `cityConfirmed` (initCityConfirmed, ниже) — ГЕЙТ на отправку city_hint. +// Deep-review R2 (#2576): слать `city` в city_hint ВСЕГДА (независимо от того, +// подтверждён он или нет) обнуляло весь смысл фичи — бэкенд трактует любой +// city_hint как «пользователь назвал город», так что необнаруженный житель +// Нижнего Тагила (дропдаун не тронут, город не упомянут в тексте) молча +// резолвился бы в Екатеринбург, а target_city_ambiguous был бы false +// практически всегда. `city` НИКОГДА не отправляется сам по себе — только +// вместе с cityConfirmed=true (см. handleSubmit / useGeocodeSuggest ниже). +function initCityLabel(address: string | undefined): string { + return detectCityInText(address) ?? DEFAULT_CITY.label; +} + +// true ⇔ город реально известен (restore: обнаружен в сохранённом адресе). +// false для свежей формы — «Екатеринбург» в `city` в этот момент ТОЛЬКО +// внутренний best-guess (для честного текста плашки), НЕ подтверждённое +// значение; дропдаун показывает UNCONFIRMED_CITY_LABEL, а не "Екатеринбург". +function initCityConfirmed(address: string | undefined): boolean { + return detectCityInText(address) != null; +} + // РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its // two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides // both ("ищем строго в пределах X м"). Design dropdown was values-only. @@ -603,6 +647,8 @@ export default function ParamsPanel({ hasEstimate = false, error = null, initialValues, + cityAmbiguous = false, + searchRadiusM = null, // markers intentionally not destructured — see the ParamsPanelProps.markers // doc comment: its %-positions belong to the retired decorative SVG grid and // do not correspond to real lat/lon on the Leaflet map below. @@ -615,6 +661,16 @@ export default function ParamsPanel({ const radiusListId = useId(); const [radiusActive, setRadiusActive] = useState(-1); const [address, setAddress] = useState(initialValues?.address ?? ""); + // Город формы (#2576 city_hint) — см. initCityLabel doc comment. Independent + // control от адреса: меняется через дропдаун ИЛИ автоопределяется из + // набранного/выбранного текста (см. handleAddressChange/pickSuggestion). + const [city, setCity] = useState(initCityLabel(initialValues?.address)); + // Гейт на отправку city_hint (deep-review R2) — см. initCityConfirmed doc + // comment. true только после явного выбора в дропдауне или совпадения + // detectCityInText; нетронутый дефолт держит его false. + const [cityConfirmed, setCityConfirmed] = useState( + initCityConfirmed(initialValues?.address), + ); const [area, setArea] = useState( initialValues?.area_m2 != null ? String(initialValues.area_m2) : "", ); @@ -676,7 +732,15 @@ export default function ParamsPanel({ // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); - const suggest = useGeocodeSuggest(addressQuery); + // #2576 / deep-review R2: подсказки скопированы по городу ТОЛЬКО когда он + // подтверждён (cityConfirmed) — иначе suggest() уходит без city_hint, и + // бэкенд смещает bias на всю область (OBLAST66_VIEWBOX), а не молча на ЕКБ. + // Это и есть исправление: нетронутая форма для «Ленина 1» теперь видит + // кандидатов из ВСЕЙ области (в т.ч. Нижний Тагил), а не только ЕКБ. + // cityHintForApi в зависимостях useGeocodeSuggest — подтверждение города + // обязано рефетчить (city/cityConfirmed оба в queryKey через этот аргумент). + const cityHintForApi = cityConfirmed ? city : undefined; + const suggest = useGeocodeSuggest(addressQuery, cityHintForApi); const handleAddressChange = (v: string) => { setAddress(v); @@ -686,6 +750,18 @@ export default function ParamsPanel({ setAddressActive(-1); // a new query invalidates the previous highlight if (fieldErrors.address) setFieldErrors((prev) => ({ ...prev, address: undefined })); + // Autodetect (#2576): если пользователь сам печатает город в тексте + // адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу — + // не ждём выбора подсказки. Найденное совпадение — реальное подтверждение + // (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где + // cityConfirmed включается без явного клика по дропдауну. Отсутствие + // совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение + // (если было) остаётся в силе при мелкой правке текста. + const detected = detectCityInText(v); + if (detected) { + setCity(detected); + setCityConfirmed(true); + } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setAddressQuery(v), 200); }; @@ -697,6 +773,30 @@ export default function ParamsPanel({ setFias(s.fias_id ?? null); setSuggestOpen(false); setAddressActive(-1); + // Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный + // сигнал подтверждения (даже сильнее, чем просто набранный текст). + // full_address обычно содержит город независимо от того, был ли отправлен + // city_hint (провайдер возвращает его как часть резолвленного адреса) — + // так первый же pick из НЕподтверждённого поиска (bias на всю область) + // подтверждает город без отдельного клика по дропдауну. + const detected = detectCityInText(s.full_address); + if (detected) { + setCity(detected); + setCityConfirmed(true); + } + }; + + // Явный выбор города из дропдауна — прямое подтверждение пользователем + // (deep-review R2: единственный способ гарантированно включить cityConfirmed + // независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС — + // тот же текст адреса под другим городом это уже другая точка (зеркалит + // handleAddressChange's stale-guard для ручной правки). + const selectCity = (v: string) => { + setCity(v); + setCityConfirmed(true); + setOpenDd(null); + setCoords(null); + setFias(null); }; // Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on @@ -830,6 +930,12 @@ export default function ParamsPanel({ lon: coords?.lon ?? null, // Детерминированный ключ дома — только из выбранной подсказки (иначе null). target_fias_id: fias, + // #2576 / deep-review R2 — город уходит ТОЛЬКО когда подтверждён + // (явный выбор в дропдауне или совпадение в тексте/подсказке); иначе + // city_hint не отправляется вовсе (undefined → ключ выпадает из JSON), + // и бэкенд честно помечает target_city_ambiguous=true вместо того + // чтобы получить непроверенный "Екатеринбург" и промолчать. + city_hint: cityConfirmed ? city : undefined, // "Авто" → null → backend keeps its two-tier default (1000 m primary / // 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по // умолчанию — это сузило бы поиск аналогов и убрало fallback.) @@ -871,9 +977,21 @@ export default function ParamsPanel({ // L.circle takes a radius in metres, so this is a true geographic scale // (unlike the old SVG ring, which was a clamped pixel best-effort). const parsedRadiusM = parseInt(radius, 10); - const circleRadiusM = Number.isFinite(parsedRadiusM) + const previewRadiusM = Number.isFinite(parsedRadiusM) ? parsedRadiusM : AUTO_RADIUS_PREVIEW_M; + // #2632: "Авто" is both the untouched default AND a valid explicit choice + // ("let the backend decide") — in either case, once a real estimate exists, + // the honest circle is the radius the backend ACTUALLY searched at + // (searchRadiusM), not our pre-submit 1 km preview guess. The moment the + // user picks a concrete radius option in this mounted session, their live + // pick wins again (preview for a not-yet-submitted "what if" change) — + // exactly the old behaviour, just no longer overridden by a stale result. + const radiusTouchedSinceMount = radius !== "Авто"; + const circleRadiusM = + !radiusTouchedSinceMount && searchRadiusM != null + ? searchRadiusM + : previewRadiusM; // Subject area caption for the map pin (M10) — the SUBJECT's own m², from the // form, so the pin never borrows an analog's area. Empty area → no caption. @@ -925,14 +1043,14 @@ export default function ParamsPanel({ coordsRef.current.lat, coordsRef.current.lon, ]; - map = L.map(mapContainerRef.current, { + map = createMap(L, mapContainerRef.current, { scrollWheelZoom: false, zoomControl: false, minZoom: MIN_MAP_ZOOM, maxZoom: MAX_MAP_ZOOM, }).setView(center, DEFAULT_MAP_ZOOM); - L.tileLayer(MAP_TILE_URL, { - attribution: MAP_ATTRIBUTION, + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, maxZoom: MAX_MAP_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); @@ -1372,6 +1490,21 @@ export default function ParamsPanel({ + {/* #2632 — honest disclosure when the circle above is NOT the radius + the dropdown says: the backend widened the search itself. Only fires + while the dropdown is still untouched in this session (see + circleRadiusM/radiusTouchedSinceMount above) — once the user picks + their own radius to preview, that choice is uncontested and there is + nothing to disclose yet. */} + {!radiusTouchedSinceMount && + searchRadiusM != null && + searchRadiusM !== previewRadiusM && ( +
+ Аналогов в радиусе {formatRadiusRu(previewRadiusM)} не хватило — + поиск расширен до {formatRadiusRu(searchRadiusM)}. +
+ )} + {/* INPUTS */}
- + + {/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не + отдельная строка: не отнимает вертикальное место у и так плотной + панели. Explicit pick сразу подтверждает город (city_hint + отправляется дальше). До подтверждения показывает + UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) — + иначе дропдаун выдавал бы наш внутренний best-guess за + подтверждённый пользователем выбор, хотя ничего ещё не + отправлялось. Для ЕКБ-сценария это не лишний клик: как только + пользователь печатает/выбирает адрес, detectCityInText обычно + находит город сам (см. handleAddressChange/pickSuggestion) и + лейбл сменится на «Екатеринбург» автоматически. Список городов + растёт — см. city-registry.ts. */} +
+
toggle("city")} + value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL} + options={CITY_LABELS} + onSelect={selectCity} + mono={false} + triggerFontSize={11} + optionFontSize={11.5} + triggerHeight={22} + variant="dashed" + ariaLabel="Город" + /> + +
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text is debounced into `addressQuery`; the dropdown below mirrors the @@ -1508,6 +1674,33 @@ export default function ParamsPanel({ {fieldErrors.address}
)} + {/* #2576 — честная, не блокирующая подсказка: последняя оценка + вернулась с target_city_ambiguous, город определил геокодер + сам. role="status" (не alert) — это информация, не ошибка. + Deep-review R3: текст различает cityConfirmed — если бэкенд + не получил city_hint (`cityConfirmed === false`), `city` в + этот момент внутренний best-guess ("Екатеринбург"), а НЕ то, + что реально нашёл геокодер (в ответе вообще нет угаданного + города, только сам булев признак) — называть конкретный город + в этом случае было бы той же нечестностью, которую фикс + убирал из запроса, только в тексте плашки. */} + {cityAmbiguous && !fieldErrors.address && ( +
+ {cityConfirmed ? ( + <> + Город определён автоматически — результат может + относиться к другому населённому пункту области. Если это + не {city}, выберите верный город выше и повторите оценку. + + ) : ( + <> + Город определён автоматически — результат может + относиться к другому населённому пункту области. Если это + неверно, выберите город выше и повторите оценку. + + )} +
+ )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index bbe9b9f1..6bb5bc15 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -157,7 +157,13 @@ export default function ResultPanel({ {data.meta.sources} - + ДОСТОВЕРНОСТЬ: {data.meta.confidence} @@ -478,6 +484,7 @@ export default function ResultPanel({ // M2 — calm delta pill (was a 51px circular gauge that read like a // tech "занижение" indicator). Full text «−18% к цене объявления», // neutral accentDeep on a soft-blue chip, tabular-nums. + <>
+ {card.exposureNote && ( +
+ {card.exposureNote} +
+ )} + )} ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index 426aa5a4..3c836620 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -6,10 +6,8 @@ * deal (estimate.actual_deals) that carries lat/lon, coloured per listing * source with a legend, target-property pin + analog-radius circle. * - * PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1 - * app/page.tsx tree, dead in prod — 307-redirected to /v2) — copied rather - * than imported so v2 stays a self-contained port with no runtime dependency - * on the legacy tree, and no npm Leaflet dep (same as MapCard). + * Leaflet + OSM грузятся через общий @/lib/leaflet (CDN + SRI, no npm dep) — + * тот же модуль, что и у остальных карт МЕРЫ. * * Deal honesty: deals are geocoded to STREET CENTROIDS (backend * scripts/geocode_deals_from_houses.py) — several deals on the same street @@ -22,6 +20,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import { safeUrl } from "@/lib/safeUrl"; import { tokens } from "./tokens"; import { @@ -37,46 +42,6 @@ import { tierLabel, } from "./mappers"; -const LEAFLET_VER = "1.9.4"; -const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; -const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; -const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; -const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; - -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard.tsx) */ -function loadLeaflet(): Promise { - return new Promise((resolve, reject) => { - const w = window as any; - if (w.L) { - resolve(w.L); - return; - } - if (!document.querySelector(`link[data-leaflet]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = LEAFLET_CSS; - link.integrity = LEAFLET_CSS_SRI; - link.crossOrigin = "anonymous"; - link.setAttribute("data-leaflet", "1"); - document.head.appendChild(link); - } - const existing = document.querySelector(`script[data-leaflet]`); - if (existing) { - existing.addEventListener("load", () => resolve(w.L)); - existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); - return; - } - const script = document.createElement("script"); - script.src = LEAFLET_JS; - script.integrity = LEAFLET_JS_SRI; - script.crossOrigin = "anonymous"; - script.setAttribute("data-leaflet", "1"); - script.onload = () => resolve(w.L); - script.onerror = () => reject(new Error("leaflet load failed")); - document.body.appendChild(script); - }); -} - // ── Per-source categorical palette ────────────────────────────────────────── // dataviz-skill categorical order (fixed, never cycled), validated against the // v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow @@ -89,7 +54,6 @@ const SOURCE_COLORS: Record = { cian: "#1baf7a", yandex: "#eda100", domklik: "#008300", - n1: "#4a3aa7", }; const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET @@ -359,10 +323,10 @@ export function SourcesMap({ estimate }: Props) { // scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay // (SectionOverlay), захват колеса мыши иначе крадёт скролл панели. - map = L.map(mapRef.current, { scrollWheelZoom: false }).setView(center, 14); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + map = createMap(L, mapRef.current, { scrollWheelZoom: false }).setView(center, 14); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 7e697102..7d1dc26d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -419,6 +419,34 @@ function KpiCell({ ); } +// fix (v2 honest-price display, item 3) — an empty `rows` array used to still +// render the header row via DataTable (visibleCols keeps every column when +// rows.length === 0, see above), so a genuinely empty analog/deal set painted +// a table with column headers and zero body rows — reading as a broken or +// still-loading table, not an honest "there is nothing here". Shown instead +// of DataTable when rows are empty; `hint` explains WHY when the caller has +// one (e.g. n_analogs === 0 → price came from Rosreestr deals instead). +function EmptyTableNote({ text, hint }: { text: string; hint?: string }) { + return ( +
+ {text} + {hint && ( +
+ {hint} +
+ )} +
+ ); +} + /** Header row + body rows wrapped in role="table"; columns are pre-filtered. */ function DataTable({ label, @@ -558,16 +586,29 @@ export default function SourcesView({ ))} - + {data.adRows.length > 0 ? ( + + ) : ( + + )} {/* Fix #1/#8 — discloses the top-10 display cap vs. the true n_analogs - total (mirrors the deals-table footer below) + any outlier exclusion. */} - {data.adsFootnote && ( + total (mirrors the deals-table footer below) + any outlier exclusion. + Skipped when the table above is already an EmptyTableNote — "Показано + 0 из 0 объявлений" adds nothing once the empty note already said so. */} + {data.adRows.length > 0 && data.adsFootnote && (
- - -
- Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "} - фактических сделок -
+ {data.dealRows.length > 0 ? ( + <> + +
+ Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "} + фактических сделок +
+ + ) : ( + + )} ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx index e3cd0b8b..9b7f7b65 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx @@ -32,6 +32,7 @@ import { tokens } from "./tokens"; import { useSupportChat } from "./SupportChatContext"; import { SupportChatPanel } from "./SupportChatPanel"; import { useSupportUnread } from "@/lib/useSupportChat"; +import type { SupportScope } from "@/lib/useSupportChat"; const { accent, accentDeep, onAccent, surface, font, danger } = tokens; @@ -45,7 +46,16 @@ const styles = ` } `; -export function SupportButton() { +interface SupportButtonProps { + /** + * "anon" — экран входа / «доступа нет», где идентити нет и быть не может. + * Дефолт "auth" — все существующие места монтирования (v2 layout) не меняются. + * См. `SupportScope` в `@/lib/useSupportChat`. + */ + scope?: SupportScope; +} + +export function SupportButton({ scope = "auth" }: SupportButtonProps = {}) { // Portal-mount guard (SSR-safe): `document` only exists after mount // (mirrors MapPicker.tsx:107-108 / BuildingListingsDrawer.tsx:29-30). const [mounted, setMounted] = useState(false); @@ -54,7 +64,7 @@ export function SupportButton() { const { open, toggleChat, closeChat } = useSupportChat(); // Unread badge only matters while the panel is closed — see // useSupportUnread's docstring for why polling stops entirely once open. - const unreadQuery = useSupportUnread(!open); + const unreadQuery = useSupportUnread(!open, scope); const unread = unreadQuery.data?.unread ?? 0; if (!mounted) return null; @@ -139,7 +149,7 @@ export function SupportButton() {
)} - + , document.body, ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx index dc00586a..e652e594 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx @@ -24,11 +24,13 @@ import { useSendSupportMessage, useSupportMessages, } from "@/lib/useSupportChat"; -import type { SupportMessage } from "@/lib/useSupportChat"; +import type { SupportMessage, SupportScope } from "@/lib/useSupportChat"; interface SupportChatPanelProps { open: boolean; onClose: () => void; + /** См. `SupportScope` — "anon" для экрана входа / «доступа нет». */ + scope?: SupportScope; } const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", { @@ -54,7 +56,11 @@ const PANEL_STYLES = ` .support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; } `; -export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) { +export function SupportChatPanel({ + open, + onClose, + scope = "auth", +}: SupportChatPanelProps) { const dialogRef = useRef(null); const inputRef = useRef(null); const listEndRef = useRef(null); @@ -64,9 +70,9 @@ export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) { const [draft, setDraft] = useState(""); - const messagesQuery = useSupportMessages(open); - const sendMessage = useSendSupportMessage(); - const markRead = useMarkSupportRead(); + const messagesQuery = useSupportMessages(open, scope); + const sendMessage = useSendSupportMessage(scope); + const markRead = useMarkSupportRead(scope); // Mark the thread read on BOTH the closed->open and open->closed edges (the // effect fires on mount-while-open and its cleanup fires on the reverse diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index fd7f78ea..241af7d8 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -10,6 +10,8 @@ import { useState } from "react"; import type { CSSProperties } from "react"; +import { API_BASE_URL } from "@/lib/api"; + import { tokens } from "./tokens"; import { navLabels, version } from "./fixtures"; import { useSupportChat } from "./SupportChatContext"; @@ -35,6 +37,12 @@ interface TopNavProps { user?: TopNavUser; // Sign out — page wires this to logout(); closes the menu first. onLogout?: () => void; + // #2556 (эпик #2549): показывать пункт «Команда» в user-меню — вычисляется + // страницей из useMe() (роль admin/manager + isPathAllowed на /api/v1/team, + // тот же двойной гейт, что и Topbar.tsx NAV_ITEMS "team"). false/undefined + // → пункт не рендерится (роль employee/pilot/analyst/expired или /me ещё + // не загрузился). + showTeamLink?: boolean; } // Neutral fallback when the user prop is absent (loading / unauthenticated). @@ -57,6 +65,24 @@ const menuItemStyle: CSSProperties = { transition: "background .12s", }; +/** Inline Users icon (lucide-react `Users` SVG path, stroke 1.2) — для + пункта «Команда» в user-меню (#2556). Тот же inline-SVG паттерн (15x15, + tokens.muted), что и остальные иконки этого дропдауна. */ +function UsersIcon() { + return ( + + ); +} + // Профиль / Настройки have no pages yet — render them dimmed and // non-interactive (no hover class, default cursor) so they read as disabled. // «Помощь» used to be in this group too, then linked out to the Telegram @@ -74,6 +100,7 @@ export default function TopNav({ reports = 0, user, onLogout, + showTeamLink = false, }: TopNavProps) { const [userOpen, setUserOpen] = useState(false); const { openChat } = useSupportChat(); @@ -489,6 +516,28 @@ export default function TopNav({ Помощь + {/* «Команда» (#2556, эпик #2549) — team-дашборд (`/team`) отдельный + роут, не таб SectionOverlay, поэтому это ссылка в user-меню + рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы — + это разделы ОДНОЙ страницы (переключают `active`/onNavigate), + а «Команда» уводит на другой URL. Гейт (showTeamLink) считает + страница из useMe() — здесь только рендер. */} + {showTeamLink && ( + setUserOpen(false)} + > + + Команда + + )} +
a.price_per_m2)).clean), nav: 2, }, @@ -1086,6 +1106,13 @@ export function mapResultPanel( emptyNote: hasExpected ? undefined : "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении", + // Median exposure days (house/district), from house_placement_history — + // gated on hasExpected: when the card has no forecast it renders only + // emptyNote (see above), the exposure aside has nowhere honest to sit. + exposureNote: + hasExpected && analytics?.kpi?.median_exposure_days != null + ? `срок продажи ~${analytics.kpi.median_exposure_days} дн` + : undefined, note: hasExpected && dealTier != null && @@ -1139,6 +1166,10 @@ export function mapResultPanel( const meta: ResultMeta = { sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence].toUpperCase(), + // M1 audit #2583 — real backend explanation, was computed but never read + // by v2 (v1 showed it). trim(): backend sometimes sends "" rather than + // null for "no explanation" — an empty tooltip string is worse than none. + confidenceExplanation: e.confidence_explanation?.trim() || undefined, cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), builtOn, sourcesNote, @@ -1250,6 +1281,9 @@ export function mapSummary( quality: { sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence], + // M1 audit #2583 — see ResultMeta.confidenceExplanation doc (mapResultPanel + // above) for the full rationale; same source field, same trim() guard. + confidenceExplanation: e.confidence_explanation?.trim() || undefined, cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), }, }; @@ -1490,9 +1524,17 @@ export function mapHistory( `медианный торг ${pct1(salesVsListings.median_discount_pct)}`, ); } - const note = - (noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") + - "Данные по улице, не по дому."; + // #2666: медиана не прошла гейт правдоподобия — отдельным предложением + // объясняем, почему числа нет (пустое место читается как поломка виджета). + const note = [ + noteParts.length > 0 ? `${noteParts.join(" · ")}.` : null, + salesVsListings?.median_discount_pct == null + ? salesVsListings?.median_discount_explanation + : null, + "Данные по улице, не по дому.", + ] + .filter(Boolean) + .join(" "); const dkpKpi = { count: streetDeals?.count != null ? String(streetDeals.count) : "—", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 3151b60c..81ca7422 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -55,6 +55,8 @@ export interface ResultCard { deltaLabel?: string; /** Reconciliation note under the value (card 2: expected price vs ДКП median). */ note?: string; + /** Медиана срока экспозиции по дому/району (house_placement_history), подстрока карточки 2. */ + exposureNote?: string; /** H1 empty-state message: when set (card 2, no expected-sold prediction) the * card renders this instead of value/range/ppm/delta, and the panel moves the * accent frame onto the «рекомендованная цена» card. */ @@ -66,6 +68,15 @@ export interface ResultCard { export interface ResultMeta { sources: string; confidence: string; + /** + * M1 audit #2583 — backend's own honest explanation of the + * confidence tier (e.g. "расширили радиус до 2 км из-за нехватки данных"). + * Was computed by the backend but never surfaced anywhere in v2 (the old + * v1 interface showed it). Rendered as a hover tooltip on ДОСТОВЕРНОСТЬ + * (mirrors HeroBar's location-index disclaimer pattern) — undefined/empty + * hides the tooltip, never a fabricated caveat. + */ + confidenceExplanation?: string; cv: string; /** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */ builtOn?: string; @@ -146,6 +157,8 @@ export interface SummaryRow { export interface SummaryQuality { sources: string; confidence: string; + /** Same backend confidence_explanation as ResultMeta — see that field's doc. */ + confidenceExplanation?: string; cv: string; } diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts new file mode 100644 index 00000000..16178d6c --- /dev/null +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -0,0 +1,105 @@ +/** + * city-registry — единый источник правды по городам Свердловской области, + * поддержанным сбором «Меры» (#2576 этап B: расширение на область). + * + * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного + * города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал + * уверенно неверную цену по одноимённой екатеринбургской улице (backend fix + * #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint` + * + честный `AggregatedEstimate.target_city_ambiguous`). + * + * ВАЖНО (deep-review R2 на #2576 UI-часть): `city_hint` — сигнал «пользователь + * НАЗВАЛ город», а не «форма его подставила». Если слать дефолт всегда, бэкенд + * трактует ЛЮБОЙ запрос как «город указан» → `target_city_ambiguous` перестаёт + * быть честным, а необнаруженный Нижний Тагил (дропдаун не тронут, город не + * упомянут в тексте) молча резолвится как Екатеринбург — ровно баг, который + * чинил backend. Поэтому вызывающий код (ParamsPanel.tsx) обязан слать + * `city_hint` ТОЛЬКО когда город реально известен (явный выбор в дропдауне ИЛИ + * `detectCityInText` нашёл совпадение) — см. `cityConfirmed` в ParamsPanel.tsx. + * Нетронутый дефолт → город НЕ отправляется. + * + * Список городов растёт по мере расширения покрытия скрейперами — не хардкодь + * города россыпью по компонентам, добавляй сюда (мирроит паттерн + * source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов + * должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES` + * (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем + * регистре. Расхождение не ломается типами (`city_hint` — свободный текст, + * бэкенд его не валидирует по списку), но подсунет город без реального + * покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка + * вручную (codegen/тест на это пока нет, backend — другой PR). + */ + +export interface OblastCity { + /** Стабильный id (React key / будущая аналитика) — НЕ отправляется бэкенду. */ + readonly id: string; + /** + * Человекочитаемое имя — ровно то, что отправляется бэкенду как `city_hint` + * (backend/app/services/geocoder.py::_resolve_city_for_geocode принимает + * произвольный текст города, не enum/slug). + */ + readonly label: string; +} + +// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок +// остальных — по охвату сбора, не алфавитный. +export const OBLAST_CITIES: readonly OblastCity[] = [ + { id: "ekaterinburg", label: "Екатеринбург" }, + { id: "nizhny_tagil", label: "Нижний Тагил" }, + { id: "kamensk_uralsky", label: "Каменск-Уральский" }, + { id: "pervouralsk", label: "Первоуральск" }, + { id: "verkhnyaya_pyshma", label: "Верхняя Пышма" }, + { id: "serov", label: "Серов" }, +]; + +/** + * Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и + * подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по + * умолчанию" — см. предупреждение в шапке файла: пока город не подтверждён + * (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит. + */ +export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; + +/** + * Лейбл дропдауна ДО подтверждения города (свежая форма, город ещё не + * выбран/определён). Честнее статичного "Екатеринбург": не выдаёт наш + * best-guess за подтверждённый выбор пользователя (deep-review R2 — + * `city_hint` не должен отправляться в этом состоянии). + * + * Fix (эпик #2576, живая проверка прода после #2584): дропдаун-триггер в + * ParamsPanel.tsx — фиксированные 176×22px, font-size 11px. Прежний текст + * "Определить автоматически" (~146px в Manrope 400 — измерено + * opentype.js-сравнением с рендером на проде) не влезал в однострочный + * бюджет ~143px (176 - padding 2×13 - место под "▼"), переносился на вторую + * строку и обрезался высотой триггера. "Автоопределение" (~96px) укладывается + * с большим запасом, сохраняя смысл — сравни с "Авто" у РАДИУС АНАЛИЗА + * (тот же проектный паттерн для автоматического режима), но без риска + * двусмысленности рядом с названиями городов. Самое длинное название города + * в CITY_LABELS, "Каменск-Уральский" (~108px), укладывается в тот же бюджет + * без переноса — проверено тем же замером, отдельного фикса не требует. + */ +export const UNCONFIRMED_CITY_LABEL = "Автоопределение"; + +/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ +export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); + +/** + * Best-effort поиск известного города области как ЦЕЛОГО токена в + * произвольном тексте (набранный адрес / `full_address` выбранной подсказки) + * — автоопределение города наравне с явным выбором из дропдауна. + * + * Токен обязан быть delimited началом/концом строки, запятой или пробелом — + * иначе "Серов" ложно сматчился бы внутри "ул. Серова" (реальная + * екатеринбургская улица, названная в честь лётчика Серова) и увёл бы выбор + * города от Екатеринбурга на Серов. JS `\b` не работает вокруг кириллицы + * (только ASCII `\w`), поэтому границы токена — явные символьные классы, как + * в v2/mappers.ts::STREET_RE. + */ +export function detectCityInText(text: string | null | undefined): string | undefined { + if (!text) return undefined; + return OBLAST_CITIES.find((c) => cityTokenRe(c.label).test(text))?.label; +} + +function cityTokenRe(label: string): RegExp { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[,\\s])${escaped}([,\\s]|$)`, "i"); +} diff --git a/tradein-mvp/frontend/src/lib/leaflet.ts b/tradein-mvp/frontend/src/lib/leaflet.ts new file mode 100644 index 00000000..88abb95f --- /dev/null +++ b/tradein-mvp/frontend/src/lib/leaflet.ts @@ -0,0 +1,108 @@ +/** + * Единая точка работы с Leaflet для карт МЕРЫ. + * + * До этого модуля loadLeaflet() и константы LEAFLET_* были дословно + * скопированы в шести компонентах (MapCard, MapPicker, SaleShareMap, + * v2/HeroBar, v2/ParamsPanel, v2/SourcesMap). Копии разъезжались: правку + * атрибуции или версии приходилось вносить шесть раз, и любая пропущенная + * копия молча оставалась со старым поведением. Здесь один источник правды. + * + * Leaflet по-прежнему грузится с CDN (unpkg) с SRI-хэшами, а не как npm-дep — + * это сознательный статус-кво, менять его отдельной задачей. + */ +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (window.L без типов) */ + +export const LEAFLET_VER = "1.9.4"; +export const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; +export const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; +export const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; +export const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; + +/** Тайлы OSM (стандартный слой). */ +export const OSM_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; + +/** Максимальный зум стандартного слоя OSM. */ +export const OSM_MAX_ZOOM = 19; + +/** + * Атрибуция тайлов OSM — обязательна по ODbL и Attribution Guidelines OSMF. + * + * Раньше во всех картах стояло просто "© OpenStreetMap": без ссылки на + * лицензию и без слова contributors, то есть требованиям не соответствовало. + * Guidelines требуют кредит «© OpenStreetMap contributors» со ссылкой на + * https://www.openstreetmap.org/copyright; само имя OpenStreetMap переводить + * нельзя, поэтому строка остаётся в канонической английской форме. + * + * Вставляется Leaflet'ом через innerHTML контрола атрибуции, поэтому это + * намеренно HTML. Строка — константа модуля, пользовательского ввода в ней + * нет. + */ +export const OSM_ATTRIBUTION = + '© OpenStreetMap contributors'; + +/** + * Подгружает Leaflet с CDN ровно один раз на страницу, резолвит window.L. + * + * Повторные вызовы либо сразу отдают уже готовый window.L, либо подписываются + * на load уже висящего в DOM